First Commit

This commit is contained in:
proitlab committed 2026-07-27 15:17:08 +07:00
commit 32a880acb0
43 files changed
+36001

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
*.md
.env
.env.local
+10
View File
@@ -0,0 +1,10 @@
# Mode: demo | live
VITE_MODE=demo
# Deployment target: local | cloud
VITE_DEPLOY=local
# Backend API URL (used in live mode)
# For local: http://localhost:8000
# For cloud: /api (proxied through same origin)
VITE_BACKEND_URL=http://localhost:8000
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+30672
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+54
View File
@@ -0,0 +1,54 @@
# AGENTS.md
Agent workflow and repo conventions. Role definitions live in `SKILLS.md`.
## 1. Workflow phases
Every `n`/`next` task runs through these phases in order:
1. **Architect** — define file layout and interface contracts before implementation starts.
2. **Backend** — implement API/data-layer logic; produce a stable contract for Frontend.
3. **Frontend** — implement UI against that contract.
4. **QA** — verify acceptance criteria, golden path, edge cases, and regression.
5. **Hardware/Compatibility** — check memory/CPU, cross-platform, and deployment constraints.
Each role hands off to the next. QA and H/W review can block `[DONE]` status.
## 2. Clarification step (§2a)
Before architecture begins, resolve ambiguity. Turn answers into concrete, verifiable acceptance criteria — what "done" means in terms of behavior and tests. QA extends these into the final checklist.
## 3. 256-LOC split rule (§3)
When a file exceeds 256 lines, the Architect decides the split boundary before anyone patches around it. No workaround commits on files that have already crossed the threshold.
## 4. Deep modules, thin handlers
- Prefer deep modules (few files with well-bounded, simple interfaces) over shallow ones (many tiny files).
- Keep business logic out of route handlers. Route handlers stay thin.
## 5. Demo/Live switch (§5)
The app supports a Demo (mock) mode and a Live mode. Both must resolve through the same contract — swapping the setting must never change calling code. The Backend wires this routing; Frontend provides the toggle control.
## 6. Cloud/Local switch (§6)
The app supports Cloud-hosted and Local/on-premise endpoints. Same rule as §5: both resolve through the same contract. Swapping the setting must never change calling code.
## 7. Key documents
- `plans/next-enhancements.md` — overall plan, structured by module/section boundaries.
- `docs/feature-list.md` — accepted trade-offs and feature documentation.
## 8. Commands
| Command | Description |
| --------------- | -------------------------------------- |
| `npm run dev` | Vite dev server (http://localhost:5173) |
| `npm run build` | `tsc -b && vite build` |
| `npm run lint` | `oxlint` |
| `npm run preview` | Preview production build |
Dev server starts on port 5173 by default (Vite convention).
Build output goes to `dist/`.
QA must verify `build` + `lint` before marking `[DONE]`.
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV VITE_MODE=demo
ENV VITE_DEPLOY=local
RUN npm run build
FROM nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+85
View File
@@ -0,0 +1,85 @@
# Skills & Roles
Five roles an agent applies during `n`/`next` execution (see `AGENTS.md`). One agent
can play all of them in sequence; a multi-agent harness may spawn each as a separate
subagent for a fresh-context pass. Order matters: Architect → Backend/Frontend → QA →
Hardware/Compatibility.
## 1. Software Architect
**Responsibilities**
- Decide where new code lives; keep module boundaries clean.
- Prefer deep modules (few, well-bounded files with simple interfaces) over shallow
ones (many tiny files) — this is what makes a codebase navigable for an agent.
- Own the 256-LOC split rule (`AGENTS.md` §3): when a file crosses the threshold,
decide the split boundary before anyone patches around it.
- Keep the overall plan (`plans/next-enhancements.md`) structured by real
module/section boundaries, not arbitrary groupings.
**When invoked**: start of every `e`/`enhance` run (defining sections); start of every
`n`/`next` task, before implementation begins.
**Handoff**: hands the Backend/Frontend roles a target file layout and interface
contract, not just a task description.
## 2. Backend Engineer
**Responsibilities**
- Implement API/data-layer logic.
- Wire the mock-vs-live routing required by the Demo/Live switch (`AGENTS.md` §5) and
the Cloud/Local endpoint switch (`AGENTS.md` §6) — both must resolve through the
same contract so swapping either setting never changes calling code.
- Keep business logic out of route handlers; route handlers stay thin.
**When invoked**: any task touching data, APIs, or service integration.
**Handoff**: gives Frontend a stable contract (types/schema) to build against; gives
QA the list of new/changed endpoints and their expected error modes.
## 3. Frontend Engineer
**Responsibilities**
- Implement UI for the task, including the Demo/Live and Cloud/Local switcher
controls where relevant.
- Consume the Backend's contract rather than reaching around it.
- Keep components small and composable, respecting the 256-LOC rule.
**When invoked**: any task with a user-facing surface.
**Handoff**: gives QA the golden-path user flow and the edge cases it's aware of.
## 4. QA / Test Engineer
**Responsibilities**
- During the clarification step (`AGENTS.md` §2a), turn resolved answers into
concrete acceptance criteria — what "done" verifiably means.
- Write/extend automated tests for the change.
- Run the **verify build integrity** pass: golden path + edge cases + regression
check on adjacent features, not just "it compiles."
- Reject work back to the relevant role if acceptance criteria aren't met — don't
patch around a failing check.
**When invoked**: acceptance-criteria drafting during §2a; final verification pass
before a task is marked `[DONE]`.
**Handoff**: reports pass/fail with specifics (what broke, under what input) back to
whichever role owns that surface.
## 5. Hardware & Performance Compatibility Reviewer
**Responsibilities**
- Check the change against realistic hardware/runtime constraints: memory and CPU
footprint, cross-platform behavior (Windows/Mac/Linux), cross-browser/device
behavior for UI work, and target-deployment limits (e.g. constrained edge/on-prem
hardware under the Local mode from `AGENTS.md` §6).
- Flag newly introduced heavy dependencies, OS-specific APIs, or assumptions that
break under Local/on-premise deployment.
- Flag anything that would degrade badly on lower-spec hardware or slower networks,
and suggest a lighter-weight alternative when one exists.
**When invoked**: final verification pass, alongside QA, before a task is marked
`[DONE]`; also whenever a task adds a new dependency or changes the deployment/runtime
surface.
**Handoff**: blocks `[DONE]` status until concerns are resolved or explicitly accepted
as a documented trade-off in `docs/feature-list.md`.
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>CustomerHub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+1694
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "customerhub",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"recharts": "^3.10.1"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+74
View File
@@ -0,0 +1,74 @@
import { Routes, Route } from 'react-router-dom'
import { loadConfig } from './config/index.ts'
import { resolveService } from './services/index.ts'
import { useCustomers } from './hooks/useCustomers.ts'
import { useAuth } from './contexts/AuthContext.tsx'
import { useTheme } from './contexts/ThemeContext.tsx'
import { Layout } from './components/Layout.tsx'
import { Dashboard } from './pages/Dashboard.tsx'
import { CustomersPage } from './pages/Customers.tsx'
import { LoginPage } from './pages/Login.tsx'
const config = loadConfig()
const service = resolveService(config)
export function App() {
const { authenticated, logout, user } = useAuth()
if (!authenticated) {
return <LoginPage />
}
return <AuthenticatedApp onLogout={logout} userDisplayName={user?.displayName ?? ''} />
}
function AuthenticatedApp({
onLogout,
userDisplayName,
}: {
onLogout: () => void
userDisplayName: string
}) {
const { customers, loading, error, search, refresh, create, update, remove } =
useCustomers(service)
const { theme, toggle: toggleTheme } = useTheme()
const settingsBar = (
<div className="settings-bar">
<button className="btn-theme" onClick={toggleTheme}>
{theme === 'light' ? '☾' : '☀'}
</button>
<span className="user-display">{userDisplayName}</span>
<button className="btn-logout" onClick={onLogout}>
Logout
</button>
</div>
)
return (
<Layout settingsBar={settingsBar}>
<Routes>
<Route
path="/"
element={<Dashboard customers={customers} loading={loading} />}
/>
<Route
path="/customers"
element={
<CustomersPage
customers={customers}
loading={loading}
error={error}
service={service}
onSearch={search}
onRefresh={refresh}
onCreate={create}
onUpdate={update}
onDelete={remove}
/>
}
/>
</Routes>
</Layout>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+161
View File
@@ -0,0 +1,161 @@
import type { UnifiedCustomer } from '../types/customer.ts'
interface CustomerDetailProps {
customer: UnifiedCustomer | null
loading: boolean
onClose: () => void
onEdit: () => void
onDelete: () => void
deleting: boolean
}
export function CustomerDetail({
customer,
loading,
onClose,
onEdit,
onDelete,
deleting,
}: CustomerDetailProps) {
if (loading) {
return (
<div className="detail-panel">
<div className="detail-header">
<button className="btn-close" onClick={onClose} title="Close">
← Back
</button>
</div>
<div className="loading-state">Loading...</div>
</div>
)
}
if (!customer) {
return null
}
return (
<div className="detail-panel">
<div className="detail-header">
<h2>{customer.display_name}</h2>
<div className="detail-header-actions">
<button className="btn-icon btn-edit" onClick={onEdit} title="Edit">
✎
</button>
<button
className="btn-icon btn-delete"
onClick={onDelete}
disabled={deleting}
title="Delete"
>
{deleting ? '...' : '🗑'}
</button>
<button className="btn-close" onClick={onClose} title="Close">
← Back
</button>
</div>
</div>
<div className="detail-body">
<section className="detail-section">
<h3>Basic Information</h3>
<dl>
<dt>ID</dt>
<dd>{customer.id}</dd>
<dt>Type</dt>
<dd>{customer.is_company ? 'Company' : 'Person'}</dd>
{customer.company_name && (
<>
<dt>Company</dt>
<dd>{customer.company_name}</dd>
</>
)}
{customer.job_position && (
<>
<dt>Position</dt>
<dd>{customer.job_position}</dd>
</>
)}
{customer.vat && (
<>
<dt>VAT</dt>
<dd>{customer.vat}</dd>
</>
)}
</dl>
</section>
<section className="detail-section">
<h3>Contact</h3>
<dl>
<dt>Email</dt>
<dd>{customer.email ?? '—'}</dd>
<dt>Phone</dt>
<dd>{customer.phone ?? '—'}</dd>
<dt>Mobile</dt>
<dd>{customer.mobile ?? '—'}</dd>
{customer.website && (
<>
<dt>Website</dt>
<dd>
<a href={customer.website} target="_blank" rel="noopener noreferrer">
{customer.website}
</a>
</dd>
</>
)}
</dl>
</section>
<section className="detail-section">
<h3>Address</h3>
<dl>
<dt>Street</dt>
<dd>{customer.street ?? '—'}</dd>
{customer.street2 && (
<>
<dt>Street 2</dt>
<dd>{customer.street2}</dd>
</>
)}
<dt>City</dt>
<dd>{customer.city ?? '—'}</dd>
<dt>State</dt>
<dd>{customer.state ?? '—'}</dd>
<dt>Country</dt>
<dd>{customer.country ?? '—'}</dd>
<dt>ZIP</dt>
<dd>{customer.zip ?? '—'}</dd>
</dl>
</section>
{customer.tags.length > 0 && (
<section className="detail-section">
<h3>Tags</h3>
<div className="tag-list">
{customer.tags.map((t) => (
<span key={t} className="tag">
{t}
</span>
))}
</div>
</section>
)}
<section className="detail-section detail-meta">
<dl>
<dt>Status</dt>
<dd>
<span className={`status-dot ${customer.active ? 'active' : 'inactive'}`} />{' '}
{customer.active ? 'Active' : 'Inactive'}
</dd>
<dt>Created</dt>
<dd>{new Date(customer.created_at).toLocaleDateString()}</dd>
<dt>Updated</dt>
<dd>{new Date(customer.updated_at).toLocaleDateString()}</dd>
</dl>
</section>
</div>
</div>
)
}
+299
View File
@@ -0,0 +1,299 @@
import { useState, useEffect } from 'react'
import type { UnifiedCustomer, CustomerFormData } from '../types/customer.ts'
interface CustomerFormProps {
customer: UnifiedCustomer | null
onSave: (data: CustomerFormData) => Promise<void>
onCancel: () => void
saving: boolean
}
const BLANK: CustomerFormData = {
name: '',
display_name: '',
type: 'contact',
is_company: false,
company_type: 'person',
email: null,
phone: null,
mobile: null,
website: null,
street: null,
street2: null,
city: null,
zip: null,
state: null,
country: null,
vat: null,
company_name: null,
job_position: null,
tags: [],
active: true,
}
export function CustomerForm({ customer, onSave, onCancel, saving }: CustomerFormProps) {
const [form, setForm] = useState<CustomerFormData>(BLANK)
useEffect(() => {
if (customer) {
setForm({
name: customer.name,
display_name: customer.display_name,
type: customer.type,
is_company: customer.is_company,
company_type: customer.company_type,
email: customer.email,
phone: customer.phone,
mobile: customer.mobile,
website: customer.website,
street: customer.street,
street2: customer.street2,
city: customer.city,
zip: customer.zip,
state: customer.state,
country: customer.country,
vat: customer.vat,
company_name: customer.company_name,
job_position: customer.job_position,
tags: customer.tags,
active: customer.active,
})
} else {
setForm(BLANK)
}
}, [customer])
const set = (field: keyof CustomerFormData, value: unknown) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.name.trim()) return
await onSave({ ...form, display_name: form.display_name || form.name })
}
const isNew = !customer
return (
<form className="customer-form" onSubmit={handleSubmit}>
<div className="form-header">
<h2>{isNew ? 'New Customer' : 'Edit Customer'}</h2>
<div className="form-header-actions">
<button type="submit" className="btn-save" disabled={saving || !form.name.trim()}>
{saving ? 'Saving...' : 'Save'}
</button>
<button type="button" className="btn-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
</div>
</div>
<div className="form-body">
<section className="form-section">
<h3>Basic Information</h3>
<label className="form-field">
<span className="field-label">Name *</span>
<input
type="text"
className="field-input"
value={form.name}
onChange={(e) => set('name', e.target.value)}
required
/>
</label>
<label className="form-field">
<span className="field-label">Display Name</span>
<input
type="text"
className="field-input"
value={form.display_name}
onChange={(e) => set('display_name', e.target.value)}
/>
</label>
<label className="form-field">
<span className="field-label">Company Type</span>
<select
className="field-input"
value={form.company_type}
onChange={(e) => {
const val = e.target.value as 'person' | 'company'
set('company_type', val)
set('is_company', val === 'company')
}}
>
<option value="person">Individual</option>
<option value="company">Company</option>
</select>
</label>
{form.is_company && (
<>
<label className="form-field">
<span className="field-label">VAT</span>
<input
type="text"
className="field-input"
value={form.vat ?? ''}
onChange={(e) => set('vat', e.target.value || null)}
/>
</label>
</>
)}
{!form.is_company && (
<>
<label className="form-field">
<span className="field-label">Company Name</span>
<input
type="text"
className="field-input"
value={form.company_name ?? ''}
onChange={(e) => set('company_name', e.target.value || null)}
/>
</label>
<label className="form-field">
<span className="field-label">Job Position</span>
<input
type="text"
className="field-input"
value={form.job_position ?? ''}
onChange={(e) => set('job_position', e.target.value || null)}
/>
</label>
</>
)}
</section>
<section className="form-section">
<h3>Contact</h3>
<label className="form-field">
<span className="field-label">Email</span>
<input
type="email"
className="field-input"
value={form.email ?? ''}
onChange={(e) => set('email', e.target.value || null)}
/>
</label>
<label className="form-field">
<span className="field-label">Phone</span>
<input
type="text"
className="field-input"
value={form.phone ?? ''}
onChange={(e) => set('phone', e.target.value || null)}
/>
</label>
<label className="form-field">
<span className="field-label">Mobile</span>
<input
type="text"
className="field-input"
value={form.mobile ?? ''}
onChange={(e) => set('mobile', e.target.value || null)}
/>
</label>
<label className="form-field">
<span className="field-label">Website</span>
<input
type="url"
className="field-input"
value={form.website ?? ''}
onChange={(e) => set('website', e.target.value || null)}
/>
</label>
</section>
<section className="form-section">
<h3>Address</h3>
<label className="form-field">
<span className="field-label">Street</span>
<input
type="text"
className="field-input"
value={form.street ?? ''}
onChange={(e) => set('street', e.target.value || null)}
/>
</label>
<label className="form-field">
<span className="field-label">Street 2</span>
<input
type="text"
className="field-input"
value={form.street2 ?? ''}
onChange={(e) => set('street2', e.target.value || null)}
/>
</label>
<label className="form-field">
<span className="field-label">City</span>
<input
type="text"
className="field-input"
value={form.city ?? ''}
onChange={(e) => set('city', e.target.value || null)}
/>
</label>
<label className="form-field">
<span className="field-label">State</span>
<input
type="text"
className="field-input"
value={form.state ?? ''}
onChange={(e) => set('state', e.target.value || null)}
/>
</label>
<label className="form-field">
<span className="field-label">Country</span>
<input
type="text"
className="field-input"
value={form.country ?? ''}
onChange={(e) => set('country', e.target.value || null)}
/>
</label>
<label className="form-field">
<span className="field-label">ZIP</span>
<input
type="text"
className="field-input"
value={form.zip ?? ''}
onChange={(e) => set('zip', e.target.value || null)}
/>
</label>
</section>
<section className="form-section">
<h3>Tags</h3>
<label className="form-field">
<span className="field-label">Tags (comma-separated)</span>
<input
type="text"
className="field-input"
value={form.tags.join(', ')}
onChange={(e) =>
set(
'tags',
e.target.value
.split(',')
.map((t) => t.trim())
.filter(Boolean),
)
}
/>
</label>
</section>
<section className="form-section">
<label className="form-field form-field-inline">
<input
type="checkbox"
className="field-checkbox"
checked={form.active}
onChange={(e) => set('active', e.target.checked)}
/>
<span className="field-label">Active</span>
</label>
</section>
</div>
</form>
)
}
+73
View File
@@ -0,0 +1,73 @@
import type { UnifiedCustomer } from '../types/customer.ts'
import { useNavigate } from 'react-router-dom'
interface CustomerListProps {
customers: UnifiedCustomer[]
loading: boolean
error: string | null
selectedId: number | null
onSelect: (id: number) => void
}
export function CustomerList({ customers, loading, error, selectedId, onSelect }: CustomerListProps) {
const navigate = useNavigate()
if (loading) {
return <div className="loading-state">Loading customers...</div>
}
if (error) {
return <div className="error-state">Error: {error}</div>
}
if (customers.length === 0) {
return <div className="empty-state">No customers found.</div>
}
return (
<div className="customer-list">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Email</th>
<th>Phone</th>
<th>City</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{customers.map((c) => (
<tr
key={c.id}
className={`table-row ${selectedId === c.id ? 'selected' : ''} ${!c.active ? 'inactive' : ''}`}
onClick={() => {
onSelect(c.id)
navigate(`/customers?id=${c.id}`)
}}
>
<td className="cell-name">
<div className="name-primary">{c.display_name}</div>
{c.company_name && <div className="name-secondary">{c.company_name}</div>}
</td>
<td>
{c.is_company ? (
<span className="tag company-tag">Company</span>
) : (
<span className="tag person-tag">Person</span>
)}
</td>
<td className="cell-email">{c.email ?? '—'}</td>
<td>{c.mobile ?? c.phone ?? '—'}</td>
<td>{c.city ?? '—'}</td>
<td>
<span className={`status-dot ${c.active ? 'active' : 'inactive'}`} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, useEffect, useRef } from 'react'
interface CustomerSearchProps {
onSearch: (query: string) => void
}
export function CustomerSearch({ onSearch }: CustomerSearchProps) {
const [value, setValue] = useState('')
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const mountedRef = useRef(false)
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true
return
}
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
onSearch(value.trim())
}, 300)
return () => clearTimeout(timerRef.current)
}, [value, onSearch])
return (
<div className="search-bar">
<input
type="text"
className="search-input"
placeholder="Search by name, email, city, or tag..."
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{value && (
<button
type="button"
className="btn-clear"
onClick={() => setValue('')}
>
Clear
</button>
)}
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, type ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
interface LayoutProps {
settingsBar: ReactNode
children: ReactNode
}
export function Layout({ settingsBar, children }: LayoutProps) {
const location = useLocation()
const [navOpen, setNavOpen] = useState(false)
return (
<div className="app-layout">
<header className="app-header">
<div className="header-left">
<button
className="hamburger"
onClick={() => setNavOpen(!navOpen)}
aria-label="Toggle navigation"
>
<span />
<span />
<span />
</button>
<h1 className="app-title">CustomerHub</h1>
<nav className={`app-nav ${navOpen ? 'open' : ''}`}>
<Link
to="/"
className={`nav-link ${location.pathname === '/' ? 'active' : ''}`}
onClick={() => setNavOpen(false)}
>
Dashboard
</Link>
<Link
to="/customers"
className={`nav-link ${location.pathname === '/customers' ? 'active' : ''}`}
onClick={() => setNavOpen(false)}
>
Customers
</Link>
</nav>
</div>
<div className="header-right">{settingsBar}</div>
</header>
<main className="app-main">{children}</main>
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
interface PaginationProps {
page: number
totalPages: number
total: number
onPageChange: (page: number) => void
}
export function Pagination({ page, totalPages, total, onPageChange }: PaginationProps) {
if (totalPages <= 1) return null
const pages: (number | 'ellipsis')[] = []
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
pages.push(i)
} else if (pages[pages.length - 1] !== 'ellipsis') {
pages.push('ellipsis')
}
}
return (
<div className="pagination">
<span className="pagination-info">
Page {page} of {totalPages} ({total} total)
</span>
<div className="pagination-controls">
<button
type="button"
className="pagination-btn"
disabled={page === 1}
onClick={() => onPageChange(page - 1)}
>
‹ Prev
</button>
{pages.map((p, i) =>
p === 'ellipsis' ? (
<span key={`e${i}`} className="pagination-ellipsis">...</span>
) : (
<button
type="button"
key={p}
className={`pagination-btn ${p === page ? 'active' : ''}`}
onClick={() => onPageChange(p)}
>
{p}
</button>
),
)}
<button
type="button"
className="pagination-btn"
disabled={page === totalPages}
onClick={() => onPageChange(page + 1)}
>
Next ›
</button>
</div>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
export type AppMode = 'demo' | 'live'
export type DeployTarget = 'cloud' | 'local'
export interface AppConfig {
mode: AppMode
deploy: DeployTarget
backendUrl: string
}
function env(key: string, fallback = ''): string {
return (import.meta.env[key] as string) ?? fallback
}
export function loadConfig(): AppConfig {
const mode: AppMode = (import.meta.env['VITE_MODE'] as AppMode) ?? 'demo'
const deploy: DeployTarget = (import.meta.env['VITE_DEPLOY'] as DeployTarget) ?? 'local'
return {
mode,
deploy,
backendUrl: env('VITE_BACKEND_URL', deploy === 'local' ? 'http://localhost:8000' : '/api'),
}
}
+71
View File
@@ -0,0 +1,71 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { validateLogin, type User } from './userStore.ts'
interface AuthState {
authenticated: boolean
user: User | null
login: (username: string, password: string) => boolean
logout: () => void
}
const AuthContext = createContext<AuthState | null>(null)
const SESSION_KEY = 'customerhub_session'
function loadSession(): { authenticated: boolean; user: User | null } {
try {
const stored = localStorage.getItem(SESSION_KEY)
if (stored) {
const parsed = JSON.parse(stored)
if (
parsed &&
typeof parsed.authenticated === 'boolean' &&
parsed.user &&
typeof parsed.user.id === 'string'
) {
return parsed
}
}
} catch {
// ignore
}
return { authenticated: false, user: null }
}
function persistSession(authenticated: boolean, user: User | null) {
localStorage.setItem(
SESSION_KEY,
JSON.stringify({ authenticated, user }),
)
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState(loadSession)
const login = useCallback((username: string, password: string): boolean => {
const user = validateLogin(username, password)
if (user) {
setState({ authenticated: true, user })
persistSession(true, user)
return true
}
return false
}, [])
const logout = useCallback(() => {
setState({ authenticated: false, user: null })
persistSession(false, null)
}, [])
return (
<AuthContext.Provider value={{ ...state, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
+46
View File
@@ -0,0 +1,46 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
type Theme = 'light' | 'dark'
interface ThemeState {
theme: Theme
toggle: () => void
}
const ThemeContext = createContext<ThemeState | null>(null)
const STORAGE_KEY = 'customerhub_theme'
function loadTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'dark' || stored === 'light') return stored
} catch {
// ignore
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(loadTheme)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem(STORAGE_KEY, theme)
}, [theme])
const toggle = useCallback(() => {
setTheme((t) => (t === 'light' ? 'dark' : 'light'))
}, [])
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme(): ThemeState {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}
+83
View File
@@ -0,0 +1,83 @@
export interface User {
id: string
username: string
password: string
displayName: string
email: string
}
const STORAGE_KEY = 'customerhub_users'
const SEED_USERS: User[] = [
{
id: 'u1',
username: 'admin',
password: 'admin',
displayName: 'Administrator',
email: 'admin@customerhub.local',
},
{
id: 'u2',
username: 'budi',
password: 'budi123',
displayName: 'Budi Santoso',
email: 'budi@customerhub.local',
},
{
id: 'u3',
username: 'siti',
password: 'siti123',
displayName: 'Siti Rahayu',
email: 'siti@customerhub.local',
},
]
function loadUsers(): User[] {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
return JSON.parse(stored) as User[]
}
} catch {
// ignore
}
return [...SEED_USERS]
}
function saveUsers(users: User[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(users))
}
export function getUsers(): User[] {
return loadUsers()
}
export function validateLogin(username: string, password: string): User | null {
const users = loadUsers()
const u = users.find(
(u) => u.username === username || u.email === username,
)
if (u && u.password === password) {
return u
}
return null
}
export function addUser(
username: string,
password: string,
displayName: string,
email: string,
): User {
const users = loadUsers()
const user: User = {
id: `u${Date.now()}`,
username,
password,
displayName,
email,
}
users.push(user)
saveUsers(users)
return user
}
+113
View File
@@ -0,0 +1,113 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type { UnifiedCustomer, CustomerFormData } from '../types/customer.ts'
import type { CustomerService } from '../services/contract.ts'
interface UseCustomersResult {
customers: UnifiedCustomer[]
loading: boolean
error: string | null
search: (query: string) => Promise<void>
refresh: () => Promise<void>
getById: (id: string) => Promise<UnifiedCustomer | null>
create: (data: CustomerFormData) => Promise<void>
update: (id: string, data: CustomerFormData) => Promise<void>
remove: (id: string) => Promise<void>
}
export function useCustomers(service: CustomerService): UseCustomersResult {
const [customers, setCustomers] = useState<UnifiedCustomer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const initialLoad = useRef(true)
const sortSet = useCallback((result: UnifiedCustomer[]) => {
setCustomers(result.sort((a, b) => a.name.localeCompare(b.name)))
}, [])
const fetchAll = useCallback(async () => {
setError(null)
try {
const result = await service.listCustomers()
sortSet(result)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load customers')
} finally {
if (initialLoad.current) {
setLoading(false)
initialLoad.current = false
}
}
}, [service, sortSet])
useEffect(() => {
void fetchAll()
}, [fetchAll])
const search = useCallback(
async (query: string) => {
if (!query.trim()) {
void fetchAll()
return
}
setError(null)
try {
const result = await service.searchCustomers(query)
sortSet(result)
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
}
},
[service, fetchAll, sortSet],
)
const getById = useCallback(
async (id: string): Promise<UnifiedCustomer | null> => {
return service.getCustomer(id)
},
[service],
)
const create = useCallback(
async (data: CustomerFormData) => {
setError(null)
try {
await service.createCustomer(data)
void fetchAll()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create customer')
throw err
}
},
[service, fetchAll],
)
const update = useCallback(
async (id: string, data: CustomerFormData) => {
setError(null)
try {
await service.updateCustomer(id, data)
void fetchAll()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update customer')
throw err
}
},
[service, fetchAll],
)
const remove = useCallback(
async (id: string) => {
setError(null)
try {
await service.deleteCustomer(id)
void fetchAll()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete customer')
throw err
}
},
[service, fetchAll],
)
return { customers, loading, error, search, refresh: fetchAll, getById, create, update, remove }
}
+1114
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from './contexts/ThemeContext.tsx'
import { AuthProvider } from './contexts/AuthContext.tsx'
import { App } from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<App />
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
</StrictMode>,
)
+201
View File
@@ -0,0 +1,201 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import type { UnifiedCustomer, CustomerFormData } from '../types/customer.ts'
import type { CustomerService } from '../services/contract.ts'
import { CustomerSearch } from '../components/CustomerSearch.tsx'
import { CustomerList } from '../components/CustomerList.tsx'
import { CustomerDetail } from '../components/CustomerDetail.tsx'
import { CustomerForm } from '../components/CustomerForm.tsx'
import { Pagination } from '../components/Pagination.tsx'
interface CustomersPageProps {
customers: UnifiedCustomer[]
loading: boolean
error: string | null
service: CustomerService
onSearch: (query: string) => void
onRefresh: () => void
onCreate: (data: CustomerFormData) => Promise<void>
onUpdate: (id: string, data: CustomerFormData) => Promise<void>
onDelete: (id: string) => Promise<void>
}
type PanelMode = 'view' | 'edit' | 'create'
export function CustomersPage({
customers,
loading,
error,
service,
onSearch,
onRefresh,
onCreate,
onUpdate,
onDelete,
}: CustomersPageProps) {
const [searchParams, setSearchParams] = useSearchParams()
const [selectedId, setSelectedId] = useState<number | null>(null)
const [detailCustomer, setDetailCustomer] = useState<UnifiedCustomer | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [panelMode, setPanelMode] = useState<PanelMode>('view')
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [page, setPage] = useState(1)
const PAGE_SIZE = 20
const idParam = searchParams.get('id')
const showPanel = detailCustomer !== null || panelMode === 'create'
const totalPages = Math.max(1, Math.ceil(customers.length / PAGE_SIZE))
const safePage = Math.min(page, totalPages)
const pageCustomers = useMemo(
() => customers.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE),
[customers, safePage],
)
const handleSearch = useCallback(
(query: string) => {
if (query) setPage(1)
onSearch(query)
},
[onSearch],
)
useEffect(() => {
if (!idParam) {
setDetailCustomer(null)
setSelectedId(null)
return
}
const numId = Number(idParam)
setSelectedId(numId)
setDetailLoading(true)
setPanelMode('view')
service.getCustomer(idParam).then((c) => {
setDetailCustomer(c)
setDetailLoading(false)
})
}, [idParam, service])
const handleSelect = (id: number) => {
setSelectedId(id)
}
const handleClosePanel = () => {
setSearchParams({})
setSelectedId(null)
setDetailCustomer(null)
setPanelMode('view')
}
const handleStartCreate = () => {
setSearchParams({})
setSelectedId(null)
setDetailCustomer(null)
setPanelMode('create')
}
const handleStartEdit = () => {
setPanelMode('edit')
}
const handleSave = async (data: CustomerFormData) => {
setSaving(true)
try {
if (panelMode === 'edit' && detailCustomer) {
await onUpdate(String(detailCustomer.id), data)
const updated = await service.getCustomer(String(detailCustomer.id))
setDetailCustomer(updated)
setPanelMode('view')
setSearchParams({ id: String(detailCustomer.id) })
} else {
await onCreate(data)
handleClosePanel()
}
} finally {
setSaving(false)
}
}
const handleCancelEdit = () => {
if (panelMode === 'edit') {
setPanelMode('view')
} else {
handleClosePanel()
}
}
const handleDelete = async () => {
if (!detailCustomer) return
if (!window.confirm(`Delete "${detailCustomer.display_name}"?`)) return
setDeleting(true)
try {
await onDelete(String(detailCustomer.id))
handleClosePanel()
} finally {
setDeleting(false)
}
}
const renderPanel = () => {
if (panelMode === 'create' || panelMode === 'edit') {
return (
<CustomerForm
customer={panelMode === 'edit' ? detailCustomer : null}
onSave={handleSave}
onCancel={handleCancelEdit}
saving={saving}
/>
)
}
return (
<CustomerDetail
customer={detailCustomer}
loading={detailLoading}
onClose={handleClosePanel}
onEdit={handleStartEdit}
onDelete={handleDelete}
deleting={deleting}
/>
)
}
return (
<div className="customers-page">
<div className="customers-toolbar">
<CustomerSearch onSearch={handleSearch} />
<button className="btn-add" onClick={handleStartCreate}>
+ Add Customer
</button>
<button className="btn-refresh" onClick={onRefresh}>
Refresh
</button>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="customers-content">
<div className={`customers-table-area ${showPanel ? 'with-detail' : ''}`}>
<CustomerList
customers={pageCustomers}
loading={loading}
error={error}
selectedId={selectedId}
onSelect={handleSelect}
/>
</div>
{showPanel && <div className="customers-detail-area">{renderPanel()}</div>}
</div>
<Pagination
page={safePage}
totalPages={totalPages}
total={customers.length}
onPageChange={setPage}
/>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { Treemap, Tooltip, ResponsiveContainer } from 'recharts'
import type { UnifiedCustomer } from '../types/customer.ts'
interface DashboardProps {
customers: UnifiedCustomer[]
loading: boolean
}
const COLORS = [
'#4f46e5', '#7c3aed', '#a855f7', '#ec4899', '#f43f5e',
'#f97316', '#eab308', '#22c55e', '#14b8a6', '#06b6d4',
'#3b82f6', '#6366f1',
]
function hashStr(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) {
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
}
return Math.abs(h)
}
export function Dashboard({ customers, loading }: DashboardProps) {
const activeCount = customers.filter((c) => c.active).length
const inactiveCount = customers.length - activeCount
const companyCount = customers.filter((c) => c.is_company).length
const personCount = customers.filter((c) => !c.is_company).length
const cityCounts = new Map<string, number>()
for (const c of customers) {
if (c.city) {
cityCounts.set(c.city, (cityCounts.get(c.city) ?? 0) + 1)
}
}
const treeData = [...cityCounts.entries()]
.map(([name, value]) => ({ name, size: value }))
.sort((a, b) => b.size - a.size)
if (loading) {
return <div className="loading-state">Loading dashboard...</div>
}
return (
<div className="dashboard">
<h2>Dashboard</h2>
<div className="stat-grid">
<div className="stat-card">
<div className="stat-value">{customers.length}</div>
<div className="stat-label">Total Customers</div>
</div>
<div className="stat-card">
<div className="stat-value">{activeCount}</div>
<div className="stat-label">Active</div>
</div>
<div className="stat-card">
<div className="stat-value">{inactiveCount}</div>
<div className="stat-label">Inactive</div>
</div>
<div className="stat-card">
<div className="stat-value">{companyCount}</div>
<div className="stat-label">Companies</div>
</div>
<div className="stat-card">
<div className="stat-value">{personCount}</div>
<div className="stat-label">Individuals</div>
</div>
<div className="stat-card">
<div className="stat-value">{cityCounts.size}</div>
<div className="stat-label">Cities</div>
</div>
</div>
{treeData.length > 0 && (
<div className="chart-section">
<h3>Customers by City</h3>
<div className="chart-responsive">
<ResponsiveContainer width="100%" height="100%">
<Treemap
data={treeData}
dataKey="size"
nameKey="name"
stroke="#fff"
fill="#4f46e5"
content={({ x, y, width, height, name, size }: Record<string, unknown>) =>
renderCell(x as number, y as number, width as number, height as number, name as string, size as number)
}
>
<Tooltip
formatter={(value, name) => [name, `${value} customer${Number(value) > 1 ? 's' : ''}`]}
labelFormatter={(label) => label}
/>
</Treemap>
</ResponsiveContainer>
</div>
</div>
)}
</div>
)
}
function renderCell(
x: number,
y: number,
width: number,
height: number,
name: string,
size: number,
) {
const color = COLORS[hashStr(name) % COLORS.length]
const showCount = width > 40 && height > 22
return (
<g>
<rect x={x} y={y} width={width} height={height} fill={color} rx={3} stroke="#fff" strokeWidth={1} />
{showCount && (
<text
x={x + width / 2}
y={y + height / 2}
textAnchor="middle"
dominantBaseline="central"
fill="#fff"
fontSize={Math.min(16, width / 4, height / 2)}
fontWeight={700}
>
{size}
</text>
)}
</g>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { useState, useMemo } from 'react'
import { useAuth } from '../contexts/AuthContext.tsx'
export function LoginPage() {
const { login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const bgImage = useMemo(
() => `url(https://picsum.photos/seed/${Math.floor(Math.random() * 1000)}/1920/1080)`,
[],
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!login(username, password)) {
setError('Invalid username or password')
}
}
return (
<div className="login-page" style={{ backgroundImage: bgImage }}>
<div className="login-card">
<h1 className="login-title">CustomerHub</h1>
<p className="login-subtitle">Sign in to continue</p>
<form className="login-form" onSubmit={handleSubmit}>
{error && <div className="login-error">{error}</div>}
<label className="login-field">
<span>Username or Email</span>
<input
type="text"
className="login-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
</label>
<label className="login-field">
<span>Password</span>
<input
type="password"
className="login-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit" className="login-btn">
Sign In
</button>
</form>
</div>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import type { UnifiedCustomer, CustomerFormData } from '../types/customer.ts'
export interface CustomerService {
listCustomers(): Promise<UnifiedCustomer[]>
getCustomer(id: string): Promise<UnifiedCustomer | null>
searchCustomers(query: string): Promise<UnifiedCustomer[]>
createCustomer(data: CustomerFormData): Promise<UnifiedCustomer>
updateCustomer(id: string, data: CustomerFormData): Promise<UnifiedCustomer>
deleteCustomer(id: string): Promise<void>
}
+473
View File
@@ -0,0 +1,473 @@
import type { UnifiedCustomer } from '../../types/customer.ts'
import { CUSTOMER_DEFAULTS } from '../../types/customer.ts'
function mk(id: number, overrides: Partial<UnifiedCustomer> = {}): UnifiedCustomer {
return {
...CUSTOMER_DEFAULTS,
id,
name: `Customer ${id}`,
display_name: `Customer ${id}`,
created_at: new Date(2024, id % 12, id).toISOString(),
updated_at: new Date(2025, (id + 3) % 12, id).toISOString(),
...overrides,
} as UnifiedCustomer
}
const FIRST_NAMES = [
'Budi', 'Siti', 'Agus', 'Dewi', 'Rudi', 'Rina', 'Anton', 'Maya',
'Hendra', 'Lina', 'Eko', 'Putri', 'Adi', 'Ratna', 'Bambang', 'Sari',
'Dian', 'Yanto', 'Fitri', 'Rizky', 'Nur', 'Ahmad', 'Sri', 'Dedi',
'Wati', 'Hadi', 'Tuti', 'Omar', 'Dewi', 'Gunawan',
]
const LAST_NAMES = [
'Santoso', 'Wijaya', 'Hartono', 'Rahayu', 'Pratama', 'Kusuma',
'Setiawan', 'Lestari', 'Susanto', 'Permata', 'Hidayat', 'Nugroho',
'Saputra', 'Wulandari', 'Gunawan', 'Handayani', 'Hermawan', 'Putra',
'Anggraini', 'Firdaus',
]
const COMPANY_PREFIX = ['PT', 'CV', 'UD', 'Toko', 'Warung', 'Bengkel', 'Apotek', 'Kedai']
const COMPANY_SUFFIX = [
'Jaya', 'Abadi', 'Makmur', 'Sejahtera', 'Berkah', 'Sukses',
'Maju', 'Lestari', 'Utama', 'Sentosa', 'Nusantara', 'Prima', 'Indah', 'Baru',
]
const CITIES: [string, string][] = [
['Jakarta Selatan', 'DKI Jakarta'],
['Jakarta Pusat', 'DKI Jakarta'],
['Jakarta Timur', 'DKI Jakarta'],
['Jakarta Barat', 'DKI Jakarta'],
['Jakarta Utara', 'DKI Jakarta'],
['Surabaya', 'Jawa Timur'],
['Malang', 'Jawa Timur'],
['Sidoarjo', 'Jawa Timur'],
['Kediri', 'Jawa Timur'],
['Bandung', 'Jawa Barat'],
['Bekasi', 'Jawa Barat'],
['Depok', 'Jawa Barat'],
['Bogor', 'Jawa Barat'],
['Tasikmalaya', 'Jawa Barat'],
['Semarang', 'Jawa Tengah'],
['Solo', 'Jawa Tengah'],
['Yogyakarta', 'DI Yogyakarta'],
['Medan', 'Sumatera Utara'],
['Palembang', 'Sumatera Selatan'],
['Pekanbaru', 'Riau'],
['Makassar', 'Sulawesi Selatan'],
['Denpasar', 'Bali'],
['Tangerang', 'Banten'],
['Balikpapan', 'Kalimantan Timur'],
['Banjarmasin', 'Kalimantan Selatan'],
['Manado', 'Sulawesi Utara'],
['Mataram', 'NTB'],
['Batam', 'Kepulauan Riau'],
['Pontianak', 'Kalimantan Barat'],
['Samarinda', 'Kalimantan Timur'],
]
const STREETS = [
'Jl. Sudirman', 'Jl. Thamrin', 'Jl. Gatot Subroto', 'Jl. Ahmad Yani',
'Jl. Diponegoro', 'Jl. Pemuda', 'Jl. Veteran', 'Jl. Merdeka',
'Jl. Raya', 'Jl. Asia Afrika', 'Jl. Kemang', 'Jl. Melati',
'Jl. Cempaka', 'Jl. Kutilang', 'Jl. Panjaitan', 'Jl. Antasari',
]
const TAGS_POOL = [
'premium', 'regular', 'retail', 'wholesale', 'distributor', 'owner',
'decision-maker', 'finance', 'manager', 'individual', 'fnb', 'beauty',
'automotive', 'electronics', 'logistics', 'pharmacy', 'florist', 'laundry',
'new', 'vip',
]
const DOMAINS = ['gmail.com', 'yahoo.com', 'outlook.com', 'company.co.id', 'bisnis.id']
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
}
function randBetween(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1))
}
function generatePhone(): string {
return `+62 8${randBetween(11, 99)} ${randBetween(1000, 9999)} ${randBetween(1000, 9999)}`
}
function generateCustomers(count: number, startId: number): UnifiedCustomer[] {
const customers: UnifiedCustomer[] = []
for (let i = 0; i < count; i++) {
const id = startId + i
const isCompany = Math.random() < 0.35
const [city, state] = pick(CITIES)
const fn = pick(FIRST_NAMES)
const ln = pick(LAST_NAMES)
const companyName = `${pick(COMPANY_PREFIX)} ${pick(COMPANY_SUFFIX)} ${pick(COMPANY_SUFFIX)}`
const fullName = `${fn} ${ln}`
const displayName = isCompany ? companyName : fullName
const hasEmail = Math.random() > 0.08
const hasPhone = Math.random() > 0.15
const tagsCount = randBetween(1, 4)
const tags = [...new Set(Array.from({ length: tagsCount }, () => pick(TAGS_POOL)))]
const createdAt = new Date(
2023 + randBetween(0, 2),
randBetween(0, 11),
randBetween(1, 28),
)
const updatedAt = new Date(
createdAt.getFullYear() + randBetween(0, 1),
randBetween(createdAt.getMonth(), 11),
randBetween(1, 28),
)
customers.push({
...CUSTOMER_DEFAULTS,
id,
name: displayName,
display_name: displayName,
type: pick(['contact', 'invoice', 'delivery', 'other']),
is_company: isCompany,
company_type: isCompany ? 'company' : 'person',
email: hasEmail
? `${fullName.toLowerCase().replace(/\s+/g, '.')}@${pick(DOMAINS)}`
: null,
phone: hasPhone ? generatePhone() : null,
mobile: Math.random() > 0.4 ? generatePhone() : null,
website: isCompany && Math.random() > 0.6 ? `https://${companyName.toLowerCase().replace(/\s+/g, '')}.co.id` : null,
street: `${pick(STREETS)} No. ${randBetween(1, 200)}`,
street2: Math.random() > 0.8 ? `Blok ${pick(['A', 'B', 'C', 'D'])} No. ${randBetween(1, 50)}` : null,
city,
state,
country: 'Indonesia',
zip: `${randBetween(10000, 99999)}`,
vat: isCompany && Math.random() > 0.5
? `${String(randBetween(1, 99)).padStart(2, '0')}.${randBetween(100, 999)}.${randBetween(100, 999)}.${randBetween(1, 9)}-${String(randBetween(1, 999)).padStart(3, '0')}.000`
: null,
company_name: !isCompany && Math.random() > 0.5 ? companyName : null,
job_position: !isCompany && Math.random() > 0.4
? pick(['Manager', 'Director', 'Owner', 'Staff', 'Supervisor', 'Purchasing', 'Finance', 'Admin'])
: null,
tags,
active: Math.random() > 0.08,
created_at: createdAt.toISOString(),
updated_at: updatedAt.toISOString(),
} as UnifiedCustomer)
}
return customers
}
export const mockCustomers: UnifiedCustomer[] = [
mk(1, {
name: 'PT Maju Bersama',
display_name: 'PT Maju Bersama',
is_company: true,
company_type: 'company',
email: 'info@majubersama.co.id',
phone: '+62 21 5555 0101',
website: 'https://majubersama.co.id',
street: 'Jl. Sudirman No. 45',
city: 'Jakarta Selatan',
state: 'DKI Jakarta',
country: 'Indonesia',
zip: '12190',
vat: '01.234.567.8-091.000',
tags: ['premium', 'distributor'],
}),
mk(2, {
name: 'CV Sumber Rezeki',
display_name: 'CV Sumber Rezeki',
is_company: true,
company_type: 'company',
email: 'admin@sumberrezeki.com',
phone: '+62 31 4444 0202',
street: 'Jl. Raya Darmo No. 12',
city: 'Surabaya',
state: 'Jawa Timur',
country: 'Indonesia',
zip: '60264',
tags: ['retail'],
}),
mk(3, {
name: 'Budi Santoso',
display_name: 'Budi Santoso',
is_company: false,
company_type: 'person',
company_name: 'PT Maju Bersama',
job_position: 'Purchasing Manager',
email: 'budi.s@majubersama.co.id',
phone: '+62 21 5555 0101',
mobile: '+62 812 3456 7890',
street: 'Jl. Sudirman No. 45',
city: 'Jakarta Selatan',
state: 'DKI Jakarta',
country: 'Indonesia',
zip: '12190',
tags: ['decision-maker'],
}),
mk(4, {
name: 'Siti Rahayu',
display_name: 'Siti Rahayu',
is_company: false,
company_type: 'person',
company_name: 'CV Sumber Rezeki',
job_position: 'Owner',
email: 'siti@sumberrezeki.com',
phone: '+62 31 4444 0202',
mobile: '+62 813 9876 5432',
street: 'Jl. Raya Darmo No. 12',
city: 'Surabaya',
state: 'Jawa Timur',
country: 'Indonesia',
zip: '60264',
tags: ['owner'],
}),
mk(5, {
name: 'Toko Elektronik Jaya',
display_name: 'Toko Elektronik Jaya',
is_company: true,
company_type: 'company',
email: 'sales@elektronikjaya.id',
phone: '+62 22 3333 0303',
website: 'https://elektronikjaya.id',
street: 'Jl. Asia Afrika No. 88',
city: 'Bandung',
state: 'Jawa Barat',
country: 'Indonesia',
zip: '40111',
vat: '02.345.678.9-012.000',
tags: ['electronics', 'retail'],
}),
mk(6, {
name: 'Agus Wijaya',
display_name: 'Agus Wijaya',
is_company: false,
company_type: 'person',
email: 'agus.wijaya@gmail.com',
mobile: '+62 811 2233 4455',
street: 'Jl. Melati No. 7',
city: 'Yogyakarta',
state: 'DI Yogyakarta',
country: 'Indonesia',
zip: '55123',
tags: ['individual'],
}),
mk(7, {
name: 'PT Logistik Nusantara',
display_name: 'PT Logistik Nusantara',
is_company: true,
company_type: 'company',
email: 'cs@logistiknusantara.co.id',
phone: '+62 21 6666 0404',
website: 'https://logistiknusantara.co.id',
street: 'Jl. MH Thamrin No. 100',
city: 'Jakarta Pusat',
state: 'DKI Jakarta',
country: 'Indonesia',
zip: '10340',
vat: '03.456.789.0-123.000',
tags: ['logistics', 'premium'],
}),
mk(8, {
name: 'Dewi Lestari',
display_name: 'Dewi Lestari',
is_company: false,
company_type: 'person',
company_name: 'PT Logistik Nusantara',
job_position: 'Finance Director',
email: 'dewi.l@logistiknusantara.co.id',
phone: '+62 21 6666 0404',
mobile: '+62 814 5566 7788',
street: 'Jl. MH Thamrin No. 100',
city: 'Jakarta Pusat',
state: 'DKI Jakarta',
country: 'Indonesia',
zip: '10340',
tags: ['finance', 'decision-maker'],
}),
mk(9, {
name: 'UD Berkah Abadi',
display_name: 'UD Berkah Abadi',
is_company: true,
company_type: 'company',
email: 'info@berkahabadi.com',
phone: '+62 24 7777 0505',
street: 'Jl. Pemuda No. 33',
city: 'Semarang',
state: 'Jawa Tengah',
country: 'Indonesia',
zip: '50132',
tags: ['wholesale'],
active: false,
}),
mk(10, {
name: 'Rina Hartono',
display_name: 'Rina Hartono',
is_company: false,
company_type: 'person',
email: 'rina.hartono@yahoo.com',
mobile: '+62 815 7788 9900',
street: 'Jl. Gatot Subroto No. 55',
city: 'Denpasar',
state: 'Bali',
country: 'Indonesia',
zip: '80235',
tags: ['individual'],
}),
mk(11, {
name: 'Warung Makan Bu Ratna',
display_name: 'Warung Makan Bu Ratna',
is_company: true,
company_type: 'company',
email: 'buratna@gmail.com',
phone: '+62 878 1111 2222',
mobile: '+62 878 1111 2222',
street: 'Jl. Merdeka No. 10',
city: 'Depok',
state: 'Jawa Barat',
country: 'Indonesia',
zip: '16424',
tags: ['fnb', 'regular'],
}),
mk(12, {
name: 'Kedai Kopi Nusantara',
display_name: 'Kedai Kopi Nusantara',
is_company: true,
company_type: 'company',
email: 'kedaikopin@gmail.com',
phone: '+62 877 3333 4444',
website: 'https://kedaikopinusantara.id',
street: 'Jl. Diponegoro No. 22',
city: 'Malang',
state: 'Jawa Timur',
country: 'Indonesia',
zip: '65119',
tags: ['fnb', 'premium'],
}),
mk(13, {
name: 'Salon Cantik',
display_name: 'Salon Cantik',
is_company: true,
company_type: 'company',
phone: '+62 876 5555 6666',
mobile: '+62 876 5555 6666',
street: 'Jl. Ahmad Yani No. 77',
city: 'Bekasi',
state: 'Jawa Barat',
country: 'Indonesia',
zip: '17141',
tags: ['beauty', 'regular'],
}),
mk(14, {
name: 'Bengkel Motor Jaya',
display_name: 'Bengkel Motor Jaya',
is_company: true,
company_type: 'company',
email: 'bengkelmotorjaya@gmail.com',
phone: '+62 875 7777 8888',
street: 'Jl. Veteran No. 15',
city: 'Tangerang',
state: 'Banten',
country: 'Indonesia',
zip: '15118',
tags: ['automotive', 'regular'],
}),
mk(15, {
name: 'Apotek Sehat Selalu',
display_name: 'Apotek Sehat Selalu',
is_company: true,
company_type: 'company',
email: 'apoteksehat@gmail.com',
phone: '+62 874 9999 0000',
street: 'Jl. Kutilang No. 5',
city: 'Bogor',
state: 'Jawa Barat',
country: 'Indonesia',
zip: '16128',
vat: '04.567.890.1-234.000',
tags: ['pharmacy', 'premium'],
}),
mk(16, {
name: 'Anton Prabowo',
display_name: 'Anton Prabowo',
is_company: false,
company_type: 'person',
company_name: 'Bengkel Motor Jaya',
job_position: 'Owner',
email: 'anton.prabowo@gmail.com',
phone: '+62 875 7777 8888',
mobile: '+62 816 1234 5678',
street: 'Jl. Veteran No. 15',
city: 'Tangerang',
state: 'Banten',
country: 'Indonesia',
zip: '15118',
tags: ['owner'],
}),
mk(17, {
name: 'Toko Bunga Indah',
display_name: 'Toko Bunga Indah',
is_company: true,
company_type: 'company',
email: 'bunga.indah@gmail.com',
phone: '+62 873 1212 3434',
street: 'Jl. Cempaka No. 42',
city: 'Solo',
state: 'Jawa Tengah',
country: 'Indonesia',
zip: '57135',
tags: ['florist', 'regular'],
active: false,
}),
mk(18, {
name: 'Maya Puspita',
display_name: 'Maya Puspita',
is_company: false,
company_type: 'person',
company_name: 'Kedai Kopi Nusantara',
job_position: 'Manager',
email: 'maya@kedaikopin.id',
phone: '+62 877 3333 4444',
mobile: '+62 817 9876 5432',
street: 'Jl. Diponegoro No. 22',
city: 'Malang',
state: 'Jawa Timur',
country: 'Indonesia',
zip: '65119',
tags: ['manager'],
}),
mk(19, {
name: 'Laundry Express',
display_name: 'Laundry Express',
is_company: true,
company_type: 'company',
email: 'laundry.express@gmail.com',
phone: '+62 872 5656 7878',
street: 'Jl. Panjaitan No. 90',
city: 'Jakarta Timur',
state: 'DKI Jakarta',
country: 'Indonesia',
zip: '13340',
tags: ['laundry', 'regular'],
}),
mk(20, {
name: 'Rudi Hartawan',
display_name: 'Rudi Hartawan',
is_company: false,
company_type: 'person',
email: 'rudi.hartawan@gmail.com',
mobile: '+62 818 4455 6677',
street: 'Jl. Kemang No. 18',
city: 'Jakarta Selatan',
state: 'DKI Jakarta',
country: 'Indonesia',
zip: '12730',
tags: ['individual'],
}),
...generateCustomers(980, 21),
]
export const nextId = { value: 1001 }
+66
View File
@@ -0,0 +1,66 @@
import type { UnifiedCustomer, CustomerFormData } from '../../types/customer.ts'
import type { CustomerService } from '../contract.ts'
import { CUSTOMER_DEFAULTS } from '../../types/customer.ts'
import { mockCustomers, nextId } from './data.ts'
export const demoService: CustomerService = {
async listCustomers(): Promise<UnifiedCustomer[]> {
await delay(300)
return [...mockCustomers]
},
async getCustomer(id: string): Promise<UnifiedCustomer | null> {
await delay(150)
const num = Number(id)
return mockCustomers.find((c) => c.id === num) ?? null
},
async searchCustomers(query: string): Promise<UnifiedCustomer[]> {
await delay(200)
const q = query.toLowerCase()
return mockCustomers.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.email?.toLowerCase().includes(q) ?? false) ||
(c.city?.toLowerCase().includes(q) ?? false) ||
c.tags.some((t) => t.toLowerCase().includes(q)),
)
},
async createCustomer(data: CustomerFormData): Promise<UnifiedCustomer> {
await delay(250)
const now = new Date().toISOString()
const customer: UnifiedCustomer = {
...CUSTOMER_DEFAULTS,
...data,
id: nextId.value++,
created_at: now,
updated_at: now,
} as UnifiedCustomer
mockCustomers.push(customer)
return customer
},
async updateCustomer(id: string, data: CustomerFormData): Promise<UnifiedCustomer> {
await delay(250)
const idx = mockCustomers.findIndex((c) => c.id === Number(id))
if (idx === -1) throw new Error(`Customer ${id} not found`)
mockCustomers[idx] = {
...mockCustomers[idx],
...data,
updated_at: new Date().toISOString(),
}
return mockCustomers[idx]
},
async deleteCustomer(id: string): Promise<void> {
await delay(200)
const num = Number(id)
const idx = mockCustomers.findIndex((c) => c.id === num)
if (idx !== -1) mockCustomers.splice(idx, 1)
},
}
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
+13
View File
@@ -0,0 +1,13 @@
import type { CustomerService } from './contract.ts'
import type { AppConfig } from '../config/index.ts'
import { demoService } from './demo/index.ts'
import { createLiveService } from './live/index.ts'
export type { CustomerService }
export function resolveService(config: AppConfig): CustomerService {
if (config.mode === 'demo') {
return demoService
}
return createLiveService(config.backendUrl)
}
+52
View File
@@ -0,0 +1,52 @@
import type { UnifiedCustomer, CustomerFormData } from '../../types/customer.ts'
import type { CustomerService } from '../contract.ts'
export function createLiveService(backendUrl: string): CustomerService {
const base = backendUrl.replace(/\/$/, '')
return {
async listCustomers(): Promise<UnifiedCustomer[]> {
const res = await fetch(`${base}/customers`)
if (!res.ok) throw new Error(`Failed to list customers: ${res.status}`)
return res.json()
},
async getCustomer(id: string): Promise<UnifiedCustomer | null> {
const res = await fetch(`${base}/customers/${id}`)
if (res.status === 404) return null
if (!res.ok) throw new Error(`Failed to get customer: ${res.status}`)
return res.json()
},
async searchCustomers(query: string): Promise<UnifiedCustomer[]> {
const res = await fetch(`${base}/customers/search?q=${encodeURIComponent(query)}`)
if (!res.ok) throw new Error(`Search failed: ${res.status}`)
return res.json()
},
async createCustomer(data: CustomerFormData): Promise<UnifiedCustomer> {
const res = await fetch(`${base}/customers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error(`Failed to create customer: ${res.status}`)
return res.json()
},
async updateCustomer(id: string, data: CustomerFormData): Promise<UnifiedCustomer> {
const res = await fetch(`${base}/customers/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error(`Failed to update customer: ${res.status}`)
return res.json()
},
async deleteCustomer(id: string): Promise<void> {
const res = await fetch(`${base}/customers/${id}`, { method: 'DELETE' })
if (!res.ok && res.status !== 404) throw new Error(`Failed to delete customer: ${res.status}`)
},
}
}
+54
View File
@@ -0,0 +1,54 @@
export interface UnifiedCustomer {
id: number
name: string
display_name: string
type: 'contact' | 'invoice' | 'delivery' | 'other'
is_company: boolean
company_type: 'person' | 'company'
email: string | null
phone: string | null
mobile: string | null
website: string | null
street: string | null
street2: string | null
city: string | null
zip: string | null
state: string | null
country: string | null
vat: string | null
company_name: string | null
job_position: string | null
tags: string[]
active: boolean
created_at: string
updated_at: string
}
export type CustomerFormData = Omit<UnifiedCustomer, 'id' | 'created_at' | 'updated_at'>
export const CUSTOMER_DEFAULTS: Partial<UnifiedCustomer> = {
type: 'contact',
is_company: false,
company_type: 'person',
email: null,
phone: null,
mobile: null,
website: null,
street: null,
street2: null,
city: null,
zip: null,
state: null,
country: null,
vat: null,
company_name: null,
job_position: null,
tags: [],
active: true,
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})