First Commit
This commit is contained in:
commit
32a880acb0
43 files changed
+36001
No files matched your search
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
.env
|
||||
.env.local
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;"]
|
||||
@@ -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
@@ -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
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+1694
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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 |
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in new issue
Block a user