Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa7d7afe9 | ||
|
|
82bbb91992 | ||
|
|
2445df8e20 | ||
|
|
78a1a905d8 | ||
|
|
b0090bc15f | ||
|
|
f5c1b7430a | ||
|
|
e316afa51d | ||
|
|
87bfe50f0e | ||
|
|
4c643547b6 | ||
|
|
6a54aa6c50 |
@@ -1,51 +1,46 @@
|
||||
---
|
||||
name: doc-updater
|
||||
description: Documentation specialist. Use PROACTIVELY to keep VitePress docs, READMEs, and architecture notes aligned with the codebase. Source of truth is apps/docs-dev plus package.json.
|
||||
description: Documentation specialist. Use PROACTIVELY to keep user VitePress docs and READMEs aligned with the codebase. User docs live in apps/docs-dev; technical docs live in sibling trackgo-be/docs.
|
||||
tools: Read, Write, Edit, Bash, Grep, Glob
|
||||
model: opus
|
||||
---
|
||||
|
||||
# Documentation Updater
|
||||
|
||||
Keep docs aligned with this frontend monorepo. Do not invent a NestJS or database map.
|
||||
Keep documentation aligned with this frontend monorepo. Follow `.cursor/rules/documentation.mdc` and `.cursor/rules/user-docs.mdc`.
|
||||
|
||||
## Documentation split
|
||||
|
||||
| Kind | Location | Audience |
|
||||
| --- | --- | --- |
|
||||
| User | `apps/docs-dev/` (VitePress) | Customers / operators — features and usage (web + mobile) |
|
||||
| Technical | sibling `trackgo-be/docs/` + `mkdocs.yml` | Developers — stack, architecture, how to run |
|
||||
|
||||
Going forward, **do not** add developer architecture, package API, desktop IPC, or setup/stack pages under `apps/docs-dev`. Prefer `src/user/web/` and `src/user/mobile/`. Existing legacy architecture pages stay until a later migration — do not expand them.
|
||||
|
||||
## Source of truth
|
||||
|
||||
1. Root and package `package.json` scripts
|
||||
2. `apps/web/.env.example`
|
||||
3. `apps/docs-dev` (VitePress) — concepts and package APIs
|
||||
4. `apps/showcase` — actual component/API shape (prefer over stale docs)
|
||||
5. Root `README.md`
|
||||
1. Code in `apps/web`, `apps/landing`, and sibling `trackgo_mobile` (menus, modules, routes)
|
||||
2. Root and package `package.json` scripts
|
||||
3. `apps/web/.env.example` (`VITE_*` only; public to the client)
|
||||
4. `apps/showcase` — component/API shape (prefer over stale docs)
|
||||
5. Root `README.md` for local run commands
|
||||
|
||||
Do not create `docs/CONTRIB.md` or `docs/CODEMAPS` unless they already exist. Prefer updating `apps/docs-dev` and the root README.
|
||||
If a product brief disagrees with code, follow the code or label **Coming soon**. No emoji. Diagrams: PlantUML, not Mermaid. Icons: `lucide-vue-next`.
|
||||
|
||||
Do not create `docs/CONTRIB.md` or `docs/CODEMAPS` unless they already exist. Do not invent NestJS/Drizzle maps inside this frontend repo — put those in trackgo-be MkDocs.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read scripts from root `package.json` (`pnpm dev:web`, `pnpm typecheck:web`, `pnpm check:all`, …)
|
||||
2. Document env vars from `apps/web/.env.example` (`VITE_*` only; they are public to the client)
|
||||
3. Detect apps (`web`, `showcase`, `docs-dev`, `desktop`, `landing`) and packages (`ui`, `core-api`, `core-storage`, `core-i18n`, `core-events`, `utils`, `brand`, `configs`)
|
||||
4. Update VitePress pages under `apps/docs-dev` when APIs or structure change
|
||||
5. List docs not touched in 90+ days for manual review
|
||||
6. Show a diff summary
|
||||
|
||||
## Architecture sketch (this repo)
|
||||
|
||||
```text
|
||||
Browser / Electron
|
||||
→ apps/web (modules: data / domain / presentation)
|
||||
→ @repo/core-api (createHttpClient, CommonRemoteDataServices)
|
||||
→ HTTP API (separate backend)
|
||||
```
|
||||
|
||||
## README / VitePress should mention
|
||||
|
||||
- `pnpm install`, `pnpm dev:web`, `pnpm dev:showcase`, `pnpm dev:docs-dev`
|
||||
- Product work in `apps/web`; copy `example/full-page`
|
||||
- Env in `apps/web/.env*`
|
||||
- Tests: `pnpm test` (Vitest)
|
||||
1. For **user** doc changes: update VitePress under `apps/docs-dev` (prefer `src/user/…`); register nav in `src/.vitepress/config.mts`
|
||||
2. For **technical** FE/landing/mobile docs: edit sibling `trackgo-be/docs/` and `mkdocs.yml` (see trackgo-be `technical-docs.mdc`)
|
||||
3. Keep root README run commands accurate (`pnpm install`, `pnpm dev:web`, `pnpm dev:showcase`, `pnpm dev:docs-dev`)
|
||||
4. List user docs not touched in 90+ days for manual review
|
||||
5. Show a diff summary
|
||||
|
||||
## Quality
|
||||
|
||||
- Every path mentioned must exist
|
||||
- Commands must match `package.json`
|
||||
- No NestJS, Drizzle, or PostgreSQL as this app's stack
|
||||
- No NestJS, Drizzle, or PostgreSQL as this app's stack in user docs
|
||||
- No emoji; PlantUML for diagrams; Lucide via `lucide-vue-next` when icons are needed
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: TrackGo docs split — user VitePress in apps/docs-dev, technical MkDocs in trackgo-be
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Documentation Split
|
||||
|
||||
TrackGo has two documentation kinds. Sibling repos: `trackgo-be`, `trackgo-fe`, `trackgo_mobile`.
|
||||
|
||||
| Kind | Audience | Location | Tooling |
|
||||
| --- | --- | --- | --- |
|
||||
| Technical | Developers / engineers | `trackgo-be/docs/` + `trackgo-be/mkdocs.yml` | MkDocs / Backstage TechDocs |
|
||||
| User | Customers / operators | `apps/docs-dev/` (this repo) | VitePress |
|
||||
|
||||
## Rules
|
||||
|
||||
- Technical: stack, architecture, how to run, module interactions (backend, web, landing, mobile) — write in trackgo-be MkDocs, not here.
|
||||
- User: features and usage for web and mobile only — write under `apps/docs-dev`.
|
||||
- Going forward, docs-dev is **user documentation only**. Do not add developer architecture, package APIs, desktop IPC, or setup/stack pages there. Existing legacy pages stay until a later migration.
|
||||
- No emoji in any documentation.
|
||||
- Diagrams and sequence diagrams: PlantUML fenced blocks (` ```plantuml `), not Mermaid.
|
||||
- Icons in user docs: Lucide via `lucide-vue-next` (VitePress is Vue). Match icon names used in `apps/web` (`lucide-react` in `menu.data.ts`). Do not paste ad-hoc SVG copies of Lucide.
|
||||
- Source of truth is the codebase. If a brief disagrees with code, follow the code or label **Coming soon**. Do not invent features.
|
||||
|
||||
Authoring details: `.cursor/rules/user-docs.mdc` (when editing `apps/docs-dev/**`).
|
||||
@@ -13,7 +13,8 @@ pnpm + Turborepo monorepo. Work from the repository root. Package manager: `pnpm
|
||||
|---|---|---|
|
||||
| **Product development** | `apps/web/` | Primary app — features, auth, modules |
|
||||
| **Component / API reference** | `apps/showcase/` | Living demos of `@repo/*` usage — copy patterns, do not ship product here |
|
||||
| Deep docs | `apps/docs-dev/` | VitePress (`pnpm dev:docs-dev`) |
|
||||
| **User documentation** | `apps/docs-dev/` | VitePress user guides (`pnpm dev:docs-dev`) — features and usage only; do not add developer architecture pages |
|
||||
| **Technical documentation** | sibling `trackgo-be/docs/` | MkDocs / TechDocs — stack, architecture, how to run (web, landing, mobile, backend) |
|
||||
| Shared UI / forms / foundations | `packages/ui` → `@repo/ui/*` | |
|
||||
| HTTP, data services, telemetry | `packages/core-api` → `@repo/core-api/*` | |
|
||||
| Storage | `packages/core-storage` → `@repo/core-storage` | |
|
||||
@@ -28,7 +29,7 @@ pnpm + Turborepo monorepo. Work from the repository root. Package manager: `pnpm
|
||||
## Hard rules
|
||||
|
||||
- Implement product features in `apps/web`, not in `showcase` or `docs-dev`.
|
||||
- Before inventing UI or package usage, match `apps/showcase` demos and `apps/docs-dev` docs.
|
||||
- Before inventing UI or package usage, match `apps/showcase` demos (component/API shape). User workflows: `apps/docs-dev`. Technical FE architecture: sibling `trackgo-be/docs/`.
|
||||
- Prefer `@repo/ui`, `@repo/core-*`, `@repo/utils` over app-local duplicates or raw Mantine/axios.
|
||||
- Env files live **inside the app** (`apps/web/.env*`), never at monorepo root. Read env via `src/core/environment` (`ENV`), not `import.meta.env` in components.
|
||||
- Run scripts from root: `pnpm dev:web`, `pnpm dev:showcase`, `pnpm lint`, `pnpm typecheck:web`, `pnpm test`, `pnpm test:e2e:web`, `pnpm check:all`.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
description: How to write TrackGo user docs in VitePress (apps/docs-dev)
|
||||
globs: apps/docs-dev/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# User Documentation (VitePress)
|
||||
|
||||
Audience: customers and operators. Location: `apps/docs-dev/`.
|
||||
|
||||
## Scope
|
||||
|
||||
Document **features and usage** (menus, screens, workflows) for:
|
||||
|
||||
- Web — `apps/web`
|
||||
- Mobile — sibling `trackgo_mobile`
|
||||
|
||||
Do **not** document tech stack, package internals, Electron IPC, or monorepo setup here. Technical docs belong in `trackgo-be/docs/` (MkDocs).
|
||||
|
||||
## Where to add pages
|
||||
|
||||
Prefer:
|
||||
|
||||
```text
|
||||
apps/docs-dev/src/user/web/
|
||||
apps/docs-dev/src/user/mobile/
|
||||
```
|
||||
|
||||
Register new pages in `src/.vitepress/config.mts` sidebar/nav.
|
||||
|
||||
## Languages
|
||||
|
||||
User guides ship in **English** (root) and **Bahasa Indonesia** (`src/id/`). When you add or edit a user page, update both locales. Keep in-app labels from web `languages/id` JSON and mobile `app_localizations_id.dart`.
|
||||
|
||||
Do **not** add new pages under the legacy architecture sidebar paths (`/packages/*`, `/apps/desktop/*`, `/setup`, `/overview`). Those stay until a later migration; do not expand them.
|
||||
|
||||
Existing user how-tos (e.g. `src/apps/web/SALES_WORKFLOW.md`): keep user-facing; when editing, convert Mermaid to PlantUML and remove emoji.
|
||||
|
||||
## Topics (only if present in code)
|
||||
|
||||
**Web** (from menus / modules — verify in `apps/web`):
|
||||
|
||||
- Master data: company settings, branches, divisions, customers, products, employees
|
||||
- Users and privileges
|
||||
- Plans (one-time / recurring) and attached invoices / packing slips
|
||||
- Live timeline / activity history
|
||||
- Import of invoices / packing slips
|
||||
- Sales flow: request → order → invoice → payment
|
||||
- Logistics packing slips
|
||||
|
||||
**Mobile** (from `trackgo_mobile/lib/ui/features/` and `lib/config/router.dart`):
|
||||
|
||||
- Branch check-in before work
|
||||
- Plans, customers / visits, invoice payment
|
||||
- Home performance / history
|
||||
|
||||
Anything in a product brief that is not in the code → **Coming soon**.
|
||||
|
||||
## Style
|
||||
|
||||
- No emoji.
|
||||
- Diagrams / sequences: PlantUML only (do not add Mermaid on new or edited pages).
|
||||
- Icons: `lucide-vue-next` in markdown `<script setup>`, same Lucide names as web menus.
|
||||
|
||||
````markdown
|
||||
```plantuml
|
||||
@startuml
|
||||
actor User
|
||||
User -> Web: Create sales order
|
||||
@enduml
|
||||
```
|
||||
````
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
</script>
|
||||
```
|
||||
|
||||
```text
|
||||
# BAD — new architecture page in docs-dev
|
||||
apps/docs-dev/src/packages/core-api/new-api.md
|
||||
|
||||
# BAD — Mermaid / emoji / React Lucide in VitePress
|
||||
```mermaid
|
||||
```
|
||||
"📦 Sales"
|
||||
import { ShoppingCart } from 'lucide-react'
|
||||
|
||||
# GOOD
|
||||
apps/docs-dev/src/user/web/sales-workflow.md
|
||||
apps/docs-dev/src/user/mobile/check-in.md
|
||||
```
|
||||
@@ -8,11 +8,11 @@ alwaysApply: false
|
||||
|
||||
Every authenticated product module in `apps/web` must be gated by `GET /auth/me` permissions. Copy Privileges (`system/privileges`) — do not invent a second RBAC path.
|
||||
|
||||
Catalog keys live in `api.md` §4 (`PRIVILEGES`, `CONFIGURATION.BRANCH`, `SALES.ORDER`, …). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`).
|
||||
Catalog keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`, `ADMIN.SALES.ACTIVITIES.ORDER`). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`).
|
||||
|
||||
## Required wiring (do all four)
|
||||
|
||||
1. **`moduleKey`** on `ModuleConfigEntity` equals the catalog `code` (e.g. `CONFIGURATION.BRANCH`).
|
||||
1. **`moduleKey`** on `ModuleConfigEntity` equals the Admin catalog `code` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`).
|
||||
2. **Menu leaf** in `layouts/data/menu.data.ts` sets the same `moduleKey`. `filterMenuByViewPrivilege` hides the item when `ALLOW_VIEW` is false.
|
||||
3. **Routes** wrap in `EnterpriseModuleProvider` so missing `ALLOW_VIEW` shows forbidden (no all-true flash).
|
||||
4. **Do not** re-check create/edit/delete in page JSX. Foundations already hide actions from `PrivilegeEntity`.
|
||||
@@ -23,8 +23,8 @@ if (!user.permissions.BRANCHES?.create) return null;
|
||||
{ key: 'branches', path: '/app/system/branches/index' }
|
||||
|
||||
// GOOD
|
||||
export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ... */ };
|
||||
{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'CONFIGURATION.BRANCH' }
|
||||
export const branchesModuleConfig = { moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH', /* ... */ };
|
||||
{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH' }
|
||||
```
|
||||
|
||||
## Flag map (`mapUserPrivileges`)
|
||||
@@ -37,6 +37,6 @@ export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ...
|
||||
| `delete` | `ALLOW_DELETE` |
|
||||
| `import` | `ALLOW_IMPORT` |
|
||||
|
||||
Missing flag → `false`. Cycles/plans: key follows `purpose` (`SALES.CYCLE` / `LOGISTICS.PLAN`), not a generic `CYCLES` key.
|
||||
Missing flag → `false`. Cycles/plans: key follows `purpose` (`ADMIN.SALES.DATA.CYCLE` / `ADMIN.LOGISTICS.ACTIVITIES.PLAN`), not a generic `CYCLES` key.
|
||||
|
||||
Reference: [apps/web/src/core/lib/map-user-privileges.ts](apps/web/src/core/lib/map-user-privileges.ts), [filter-menu-by-view-privilege.ts](apps/web/src/core/lib/filter-menu-by-view-privilege.ts).
|
||||
|
||||
@@ -240,36 +240,43 @@ HTTP mapping:
|
||||
| `DELETE /:id`, `POST /bulk-delete` | `delete` |
|
||||
| `POST /import` | `import` |
|
||||
|
||||
Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`):
|
||||
Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`). Keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule`:
|
||||
|
||||
| code | label |
|
||||
| ------------------------ | ---------------- |
|
||||
| `PRIVILEGES` | Privileges |
|
||||
| `USERS` | Users |
|
||||
| `CONFIGURATION.DIVISION` | Divisions |
|
||||
| `CONFIGURATION.BRANCH` | Branches |
|
||||
| `CONFIGURATION.CUSTOMER` | Customers |
|
||||
| `CONFIGURATION.EMPLOYEE` | Employees |
|
||||
| `CONFIGURATION.PRODUCT` | Products |
|
||||
| `SALES.REQUEST` | Sales requests |
|
||||
| `SALES.ORDER` | Sales orders |
|
||||
| `SALES.PACKING_SLIP` | Packing slips |
|
||||
| `SALES.INVOICE` | Sales invoices |
|
||||
| `SALES.PAYMENT` | Sales payments |
|
||||
| `CONFIGURATION.SETTING` | Company settings |
|
||||
| `SALES.CYCLE` | Sales cycles |
|
||||
| `SALES.PLAN` | Sales plans |
|
||||
| `LOGISTICS.CYCLE` | Logistics cycles |
|
||||
| `LOGISTICS.PLAN` | Logistics plans |
|
||||
| code | label |
|
||||
| ----------------------------------------- | ----------------------- |
|
||||
| `ADMIN.SETTINGS.USER.PRIVILEGES` | Privileges |
|
||||
| `ADMIN.SETTINGS.USER.USERS` | Users |
|
||||
| `ADMIN.SETTINGS.DATA.DIVISION` | Divisions |
|
||||
| `ADMIN.SETTINGS.DATA.BRANCH` | Branches |
|
||||
| `ADMIN.SETTINGS.DATA.CUSTOMER` | Customers |
|
||||
| `ADMIN.SETTINGS.DATA.PRODUCT` | Products |
|
||||
| `ADMIN.SETTINGS.DATA.SETTING` | Company settings |
|
||||
| `ADMIN.SALES.DATA.EMPLOYEE` | Employees |
|
||||
| `ADMIN.SALES.DATA.CYCLE` | Sales cycles |
|
||||
| `ADMIN.SALES.ACTIVITIES.REQUEST` | Sales requests |
|
||||
| `ADMIN.SALES.ACTIVITIES.ORDER` | Sales orders |
|
||||
| `ADMIN.SALES.ACTIVITIES.INVOICE` | Sales invoices |
|
||||
| `ADMIN.SALES.ACTIVITIES.PAYMENT` | Sales payments |
|
||||
| `ADMIN.SALES.ACTIVITIES.PLAN` | Sales plans |
|
||||
| `ADMIN.SALES.ACTIVITIES.TIMELINE` | Sales timeline |
|
||||
| `ADMIN.SALES.REPORT` | Sales reports |
|
||||
| `ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP` | Packing slips |
|
||||
| `ADMIN.LOGISTICS.DATA.CYCLE` | Logistics cycles |
|
||||
| `ADMIN.LOGISTICS.ACTIVITIES.PLAN` | Logistics plans |
|
||||
| `ADMIN.LOGISTICS.REPORT` | Logistics reports |
|
||||
| `MOBILE.SALES.PLAN` | Sales plans (mobile) |
|
||||
| `MOBILE.SALES.PLAN.ATTENDANCE` | Branch attendance |
|
||||
| `MOBILE.SALES.VISIT` | Customer visits |
|
||||
| `MOBILE.SALES.TIMELINE` | Sales timeline (mobile) |
|
||||
|
||||
### Field purpose
|
||||
|
||||
Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`:
|
||||
|
||||
| purpose | cycle key | plan key |
|
||||
| ----------- | ----------------- | ---------------- |
|
||||
| `sales` | `SALES.CYCLE` | `SALES.PLAN` |
|
||||
| `logistics` | `LOGISTICS.CYCLE` | `LOGISTICS.PLAN` |
|
||||
| purpose | cycle keys | plan keys |
|
||||
| ----------- | ---------------------------- | ---------------------------------------------------------- |
|
||||
| `sales` | `ADMIN.SALES.DATA.CYCLE` | `ADMIN.SALES.ACTIVITIES.PLAN`, `MOBILE.SALES.PLAN` |
|
||||
| `logistics` | `ADMIN.LOGISTICS.DATA.CYCLE` | `ADMIN.LOGISTICS.ACTIVITIES.PLAN`, `MOBILE.LOGISTICS.PLAN` |
|
||||
|
||||
`purpose` is read from **body** (writes) or **query** (lists). If omitted, the user may proceed if they have the action on **either** purpose; list results are filtered to purposes they can view. Superadmin bypasses.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@repo/ui": "workspace:*",
|
||||
"dayjs": "^1.11.19",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
@@ -20,6 +21,7 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"eslint": "^8.57.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"plantuml-encoder": "^1.4.0",
|
||||
"typescript": "5.5.4",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitepress-plugin-mermaid": "^2.0.17",
|
||||
|
||||
@@ -1,111 +1,196 @@
|
||||
import { defineConfig } from 'vitepress';
|
||||
import { withMermaid } from 'vitepress-plugin-mermaid';
|
||||
import { plantumlPlugin } from './plantuml';
|
||||
|
||||
const giteaIcon = {
|
||||
svg: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><title>Gitea</title><path d="M4.209 4.603c-0.247 0 -0.525 0.02 -0.84 0.088 -0.333 0.07 -1.28 0.283 -2.054 1.027C-0.403 7.25 0.035 9.685 0.089 10.052c0.065 0.446 0.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s0.462 1.103 1.168 2.119c0.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212 -0.004 7.212 -0.004s0.458 0.004 1.08 -0.394c0.535 -0.324 1.013 -0.893 1.013 -0.893s0.492 -0.527 1.18 -1.73c0.21 -0.37 0.385 -0.729 0.538 -1.068 0 0 2.107 -4.471 2.107 -8.823 -0.042 -1.318 -0.367 -1.55 -0.443 -1.627 -0.156 -0.156 -0.366 -0.153 -0.366 -0.153s-4.475 0.252 -6.792 0.306c-0.508 0.011 -1.012 0.023 -1.512 0.027v4.474l-0.634 -0.301c0 -1.39 -0.004 -4.17 -0.004 -4.17 -1.107 0.016 -3.405 -0.084 -3.405 -0.084s-5.399 -0.27 -5.987 -0.324c-0.187 -0.011 -0.401 -0.032 -0.648 -0.032zm0.354 1.832h0.111s0.271 2.269 0.6 3.597C5.549 11.147 6.22 13 6.22 13s-0.996 -0.119 -1.641 -0.348c-0.99 -0.324 -1.409 -0.714 -1.409 -0.714s-0.73 -0.511 -1.096 -1.52C1.444 8.73 2.021 7.7 2.021 7.7s0.32 -0.859 1.47 -1.145c0.395 -0.106 0.863 -0.12 1.072 -0.12zm8.33 2.554c0.26 0.003 0.509 0.127 0.509 0.127l0.868 0.422 -0.529 1.075a0.686 0.686 0 0 0 -0.614 0.359 0.685 0.685 0 0 0 0.072 0.756l-0.939 1.924a0.69 0.69 0 0 0 -0.66 0.527 0.687 0.687 0 0 0 0.347 0.763 0.686 0.686 0 0 0 0.867 -0.206 0.688 0.688 0 0 0 -0.069 -0.882l0.916 -1.874a0.667 0.667 0 0 0 0.237 -0.02 0.657 0.657 0 0 0 0.271 -0.137 8.826 8.826 0 0 1 1.016 0.512 0.761 0.761 0 0 1 0.286 0.282c0.073 0.21 -0.073 0.569 -0.073 0.569 -0.087 0.29 -0.702 1.55 -0.702 1.55a0.692 0.692 0 0 0 -0.676 0.477 0.681 0.681 0 1 0 1.157 -0.252c0.073 -0.141 0.141 -0.282 0.214 -0.431 0.19 -0.397 0.515 -1.16 0.515 -1.16 0.035 -0.066 0.218 -0.394 0.103 -0.814 -0.095 -0.435 -0.48 -0.638 -0.48 -0.638 -0.467 -0.301 -1.116 -0.58 -1.116 -0.58s0 -0.156 -0.042 -0.27a0.688 0.688 0 0 0 -0.148 -0.241l0.516 -1.062 2.89 1.401s0.48 0.218 0.583 0.619c0.073 0.282 -0.019 0.534 -0.069 0.657 -0.24 0.587 -2.1 4.317 -2.1 4.317s-0.232 0.554 -0.748 0.588a1.065 1.065 0 0 1 -0.393 -0.045l-0.202 -0.08 -4.31 -2.1s-0.417 -0.218 -0.49 -0.596c-0.083 -0.31 0.104 -0.691 0.104 -0.691l2.073 -4.272s0.183 -0.37 0.466 -0.497a0.855 0.855 0 0 1 0.35 -0.077z" fill="currentColor" stroke-width="1"></path></svg>',
|
||||
};
|
||||
|
||||
const developerSidebar = {
|
||||
text: 'Developer reference',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'Project Overview', link: '/overview' },
|
||||
{ text: 'Development Setup', link: '/setup' },
|
||||
{ text: 'API Engine', link: '/packages/core-api' },
|
||||
{ text: 'Data Transformers', link: '/packages/core-api/transformers' },
|
||||
{ text: 'Event Bus System', link: '/packages/core-events/' },
|
||||
{ text: 'Storage & Persistence', link: '/packages/core-storage/' },
|
||||
{ text: 'I18n & Localization', link: '/packages/core-i18n/' },
|
||||
{ text: 'UI Overview', link: '/packages/ui/' },
|
||||
{ text: 'App Layout', link: '/packages/ui/CORE-APP-SHELL' },
|
||||
{ text: 'Action Tools', link: '/packages/ui/ACTION-TOOLS' },
|
||||
{ text: 'Form Primitives', link: '/packages/ui/FORM-COMPONENTS' },
|
||||
{ text: 'Web Overview', link: '/apps/web/' },
|
||||
{ text: 'Sales Workflow (legacy)', link: '/apps/web/SALES_WORKFLOW' },
|
||||
{ text: 'Desktop Overview', link: '/apps/desktop/' },
|
||||
{ text: 'Lifecycle & Configuration', link: '/apps/desktop/CONFIGURATION' },
|
||||
{ text: 'IPC & Bridge Architecture', link: '/apps/desktop/IPC_ARCHITECTURE' },
|
||||
{ text: 'Distribution & Auto-Update', link: '/apps/desktop/AUTO_UPDATER' },
|
||||
],
|
||||
};
|
||||
|
||||
const config = withMermaid(
|
||||
defineConfig({
|
||||
// title: "Frontend Monorepo",
|
||||
title: 'Frontend Arch',
|
||||
description: 'Centralized documentation for the Enterprise Frontend Monorepo',
|
||||
head: [
|
||||
['link', { rel: 'icon', href: '/favicon.svg' }], // Jika Anda menggunakan favicon.svg
|
||||
],
|
||||
title: 'TrackGo',
|
||||
description: 'User guides for TrackGo web and mobile',
|
||||
head: [['link', { rel: 'icon', href: '/favicon.svg' }]],
|
||||
markdown: {
|
||||
config(md) {
|
||||
plantumlPlugin(md);
|
||||
},
|
||||
},
|
||||
locales: {
|
||||
root: {
|
||||
label: 'English',
|
||||
lang: 'en',
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: 'User guides', link: '/user/' },
|
||||
{ text: 'Web', link: '/user/web/' },
|
||||
{ text: 'Mobile', link: '/user/mobile/' },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: 'User guides',
|
||||
items: [{ text: 'What is TrackGo', link: '/user/' }],
|
||||
},
|
||||
{
|
||||
text: 'Web',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Overview', link: '/user/web/' },
|
||||
{ text: 'Set up your company', link: '/user/web/setup' },
|
||||
{ text: 'Plans and visits', link: '/user/web/plans' },
|
||||
{ text: 'Sales workflow', link: '/user/web/sales-workflow' },
|
||||
{ text: 'Logistics', link: '/user/web/logistics' },
|
||||
{ text: 'Timeline', link: '/user/web/timeline' },
|
||||
{ text: 'Import CSV', link: '/user/web/import' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Mobile',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Overview', link: '/user/mobile/' },
|
||||
{ text: 'Check in', link: '/user/mobile/check-in' },
|
||||
{ text: 'Daily work', link: '/user/mobile/daily-work' },
|
||||
],
|
||||
},
|
||||
developerSidebar,
|
||||
],
|
||||
},
|
||||
},
|
||||
id: {
|
||||
label: 'Bahasa Indonesia',
|
||||
lang: 'id',
|
||||
link: '/id/',
|
||||
description: 'Panduan pengguna TrackGo untuk web dan mobile',
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: 'Panduan pengguna', link: '/id/user/' },
|
||||
{ text: 'Web', link: '/id/user/web/' },
|
||||
{ text: 'Mobile', link: '/id/user/mobile/' },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: 'Panduan pengguna',
|
||||
items: [{ text: 'Apa itu TrackGo', link: '/id/user/' }],
|
||||
},
|
||||
{
|
||||
text: 'Web',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Ringkasan', link: '/id/user/web/' },
|
||||
{ text: 'Siapkan perusahaan', link: '/id/user/web/setup' },
|
||||
{ text: 'Rencana dan kunjungan', link: '/id/user/web/plans' },
|
||||
{ text: 'Alur penjualan', link: '/id/user/web/sales-workflow' },
|
||||
{ text: 'Logistik', link: '/id/user/web/logistics' },
|
||||
{ text: 'Timeline', link: '/id/user/web/timeline' },
|
||||
{ text: 'Impor CSV', link: '/id/user/web/import' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Mobile',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Ringkasan', link: '/id/user/mobile/' },
|
||||
{ text: 'Check-in', link: '/id/user/mobile/check-in' },
|
||||
{ text: 'Kerja harian', link: '/id/user/mobile/daily-work' },
|
||||
],
|
||||
},
|
||||
],
|
||||
outline: { label: 'Di halaman ini', level: [2, 3] },
|
||||
docFooter: {
|
||||
prev: 'Halaman sebelumnya',
|
||||
next: 'Halaman berikutnya',
|
||||
},
|
||||
darkModeSwitchLabel: 'Tema',
|
||||
lightModeSwitchTitle: 'Ubah ke mode terang',
|
||||
darkModeSwitchTitle: 'Ubah ke mode gelap',
|
||||
sidebarMenuLabel: 'Menu',
|
||||
returnToTopLabel: 'Kembali ke atas',
|
||||
langMenuLabel: 'Ubah bahasa',
|
||||
},
|
||||
},
|
||||
},
|
||||
themeConfig: {
|
||||
search: {
|
||||
provider: 'local',
|
||||
options: {
|
||||
detailedView: true,
|
||||
locales: {
|
||||
id: {
|
||||
translations: {
|
||||
button: {
|
||||
buttonText: 'Cari',
|
||||
buttonAriaLabel: 'Cari',
|
||||
},
|
||||
modal: {
|
||||
displayDetails: 'Tampilkan tampilan terperinci',
|
||||
resetButtonTitle: 'Reset pencarian',
|
||||
backButtonTitle: 'Tutup pencarian',
|
||||
noResultsText: 'Tidak ada hasil untuk',
|
||||
footer: {
|
||||
selectText: 'pilih',
|
||||
selectKeyAriaLabel: 'Enter',
|
||||
navigateText: 'navigasi',
|
||||
navigateUpKeyAriaLabel: 'Panah atas',
|
||||
navigateDownKeyAriaLabel: 'Panah bawah',
|
||||
closeText: 'tutup',
|
||||
closeKeyAriaLabel: 'Escape',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
logo: '/logo.svg',
|
||||
nav: [{ text: 'Docs', link: '/overview' }],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: 'Getting Started',
|
||||
items: [
|
||||
{ text: 'Project Overview', link: '/overview' },
|
||||
{ text: 'Development Setup', link: '/setup' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Core Architecture',
|
||||
collapsed: false,
|
||||
items: [
|
||||
// { text: 'API & Domain Logic', link: '/packages/core-api/' },
|
||||
{
|
||||
text: 'API & Domain Logic',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'API Engine', link: '/packages/core-api' },
|
||||
{ text: 'Data Transformers', link: '/packages/core-api/transformers' },
|
||||
],
|
||||
},
|
||||
|
||||
{ text: 'Event Bus System', link: '/packages/core-events/' },
|
||||
{ text: 'Storage & Persistence', link: '/packages/core-storage/' },
|
||||
{ text: 'I18n & Localization', link: '/packages/core-i18n/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'UI System',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Overview', link: '/packages/ui/' },
|
||||
{ text: 'App Layout', link: '/packages/ui/CORE-APP-SHELL' },
|
||||
{ text: 'Action Tools', link: '/packages/ui/ACTION-TOOLS' },
|
||||
{ text: 'Form Primitives', link: '/packages/ui/FORM-COMPONENTS' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'TrackGo Web',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Overview', link: '/apps/web/' },
|
||||
{ text: 'Sales Workflow', link: '/apps/web/SALES_WORKFLOW' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Desktop Ecosystem',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Overview', link: '/apps/desktop/' },
|
||||
{ text: 'Lifecycle & Configuration', link: '/apps/desktop/CONFIGURATION' },
|
||||
{ text: 'IPC & Bridge Architecture', link: '/apps/desktop/IPC_ARCHITECTURE' },
|
||||
{ text: 'Distribution & Auto-Update', link: '/apps/desktop/AUTO_UPDATER' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
outline: { level: [2, 3] },
|
||||
socialLinks: [
|
||||
{
|
||||
icon: {
|
||||
svg: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><title>Gitea</title><path d="M4.209 4.603c-0.247 0 -0.525 0.02 -0.84 0.088 -0.333 0.07 -1.28 0.283 -2.054 1.027C-0.403 7.25 0.035 9.685 0.089 10.052c0.065 0.446 0.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s0.462 1.103 1.168 2.119c0.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212 -0.004 7.212 -0.004s0.458 0.004 1.08 -0.394c0.535 -0.324 1.013 -0.893 1.013 -0.893s0.492 -0.527 1.18 -1.73c0.21 -0.37 0.385 -0.729 0.538 -1.068 0 0 2.107 -4.471 2.107 -8.823 -0.042 -1.318 -0.367 -1.55 -0.443 -1.627 -0.156 -0.156 -0.366 -0.153 -0.366 -0.153s-4.475 0.252 -6.792 0.306c-0.508 0.011 -1.012 0.023 -1.512 0.027v4.474l-0.634 -0.301c0 -1.39 -0.004 -4.17 -0.004 -4.17 -1.107 0.016 -3.405 -0.084 -3.405 -0.084s-5.399 -0.27 -5.987 -0.324c-0.187 -0.011 -0.401 -0.032 -0.648 -0.032zm0.354 1.832h0.111s0.271 2.269 0.6 3.597C5.549 11.147 6.22 13 6.22 13s-0.996 -0.119 -1.641 -0.348c-0.99 -0.324 -1.409 -0.714 -1.409 -0.714s-0.73 -0.511 -1.096 -1.52C1.444 8.73 2.021 7.7 2.021 7.7s0.32 -0.859 1.47 -1.145c0.395 -0.106 0.863 -0.12 1.072 -0.12zm8.33 2.554c0.26 0.003 0.509 0.127 0.509 0.127l0.868 0.422 -0.529 1.075a0.686 0.686 0 0 0 -0.614 0.359 0.685 0.685 0 0 0 0.072 0.756l-0.939 1.924a0.69 0.69 0 0 0 -0.66 0.527 0.687 0.687 0 0 0 0.347 0.763 0.686 0.686 0 0 0 0.867 -0.206 0.688 0.688 0 0 0 -0.069 -0.882l0.916 -1.874a0.667 0.667 0 0 0 0.237 -0.02 0.657 0.657 0 0 0 0.271 -0.137 8.826 8.826 0 0 1 1.016 0.512 0.761 0.761 0 0 1 0.286 0.282c0.073 0.21 -0.073 0.569 -0.073 0.569 -0.087 0.29 -0.702 1.55 -0.702 1.55a0.692 0.692 0 0 0 -0.676 0.477 0.681 0.681 0 1 0 1.157 -0.252c0.073 -0.141 0.141 -0.282 0.214 -0.431 0.19 -0.397 0.515 -1.16 0.515 -1.16 0.035 -0.066 0.218 -0.394 0.103 -0.814 -0.095 -0.435 -0.48 -0.638 -0.48 -0.638 -0.467 -0.301 -1.116 -0.58 -1.116 -0.58s0 -0.156 -0.042 -0.27a0.688 0.688 0 0 0 -0.148 -0.241l0.516 -1.062 2.89 1.401s0.48 0.218 0.583 0.619c0.073 0.282 -0.019 0.534 -0.069 0.657 -0.24 0.587 -2.1 4.317 -2.1 4.317s-0.232 0.554 -0.748 0.588a1.065 1.065 0 0 1 -0.393 -0.045l-0.202 -0.08 -4.31 -2.1s-0.417 -0.218 -0.49 -0.596c-0.083 -0.31 0.104 -0.691 0.104 -0.691l2.073 -4.272s0.183 -0.37 0.466 -0.497a0.855 0.855 0 0 1 0.35 -0.077z" fill="currentColor" stroke-width="1"></path></svg>',
|
||||
},
|
||||
icon: giteaIcon,
|
||||
link: 'https://git.eigen.co.id/eigen/fe-monorepo-template',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Mermaid configuration
|
||||
mermaid: {
|
||||
theme: 'default',
|
||||
},
|
||||
|
||||
// Fix cascading CJS/ESM SyntaxErrors caused by Vite dynamically discovering mermaid
|
||||
vite: {
|
||||
ssr: {
|
||||
noExternal: ['lucide-vue-next'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['mermaid'],
|
||||
include: ['mermaid', 'lucide-vue-next'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Pnpm strict workspace workaround:
|
||||
// vitepress-plugin-mermaid aggressively injects sub-dependencies into optimizeDeps.include.
|
||||
// Because pnpm uses strict symlinks, Vite fails to resolve these sub-dependencies from the project root,
|
||||
// causing pre-bundling to fail and cascading CJS/ESM SyntaxErrors in the browser.
|
||||
// We strip them out so esbuild can naturally inline them into the 'mermaid' chunk instead.
|
||||
if (config.vite?.optimizeDeps?.include) {
|
||||
config.vite.optimizeDeps.include = config.vite.optimizeDeps.include.filter(
|
||||
(dep) => !['@braintree/sanitize-url', 'debug', 'cytoscape-cose-bilkent', 'cytoscape'].includes(dep),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module 'plantuml-encoder' {
|
||||
export function encode(source: string): string;
|
||||
export function decode(encoded: string): string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const plantumlEncoder = require('plantuml-encoder') as {
|
||||
encode: (source: string) => string;
|
||||
};
|
||||
|
||||
export function plantumlPlugin(md: MarkdownIt): void {
|
||||
const defaultFence = md.renderer.rules.fence;
|
||||
if (!defaultFence) {
|
||||
return;
|
||||
}
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
|
||||
const token = tokens[idx];
|
||||
const info = token.info.trim().split(/\s+/)[0];
|
||||
if (info === 'plantuml') {
|
||||
const encoded = plantumlEncoder.encode(token.content);
|
||||
const src = `https://www.plantuml.com/plantuml/svg/${encoded}`;
|
||||
return `<div class="plantuml-diagram"><img src="${src}" alt="diagram" /></div>\n`;
|
||||
}
|
||||
return defaultFence(tokens, idx, options, env, slf);
|
||||
};
|
||||
}
|
||||
@@ -113,4 +113,202 @@
|
||||
.VPNavBar .VPNavBarSearch {
|
||||
flex-grow: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
display: inline-block;
|
||||
vertical-align: -0.2em;
|
||||
color: var(--vp-c-brand-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plantuml-diagram {
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 16px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.plantuml-diagram img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.VPHome {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.VPHome::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100vw;
|
||||
max-width: 1200px;
|
||||
height: 800px;
|
||||
background: radial-gradient(ellipse at top, var(--vp-c-brand-soft) 0%, transparent 70%);
|
||||
filter: blur(100px);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.VPHome::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
background-image: radial-gradient(var(--vp-c-divider) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
background-position: top center;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
||||
}
|
||||
|
||||
.custom-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--vp-c-divider);
|
||||
margin: 80px 0 64px 0;
|
||||
}
|
||||
|
||||
.bento-container {
|
||||
max-width: 1152px;
|
||||
margin: auto;
|
||||
padding: 0 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bento-header {
|
||||
text-align: center;
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
|
||||
.bento-header h2 {
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: var(--vp-c-text-1);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.bento-header p {
|
||||
font-size: 1.15em;
|
||||
color: var(--vp-c-text-2);
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bento-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.bento-card {
|
||||
background: var(--vp-c-bg-soft);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 16px;
|
||||
padding: 40px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
|
||||
.bento-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 20px 25px -5px rgba(101, 46, 217, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.bento-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-mute);
|
||||
color: var(--vp-c-brand-1);
|
||||
margin-bottom: 24px;
|
||||
transition: background 0.3s ease, color 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.bento-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.bento-card:hover .bento-icon {
|
||||
background: var(--vp-c-brand-1);
|
||||
color: #ffffff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.bento-card h3 {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bento-card p {
|
||||
font-size: 1em;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.custom-divider {
|
||||
margin: 48px 0;
|
||||
}
|
||||
.bento-header {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.bento-header h2 {
|
||||
font-size: 2em;
|
||||
}
|
||||
.bento-header p {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.bento-card {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bento-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.bento-card {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +1,3 @@
|
||||
# Sales Workflow
|
||||
|
||||
How to run a sale in TrackGo: from a request or order, through packing and invoicing, to payment.
|
||||
|
||||
You need permission to view each menu. Status buttons (Process, Complete, Submit, Approve, and so on) appear only if you are allowed to edit.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
Set up the records that every sales document needs:
|
||||
|
||||
| You need | Where to create it |
|
||||
| ----------------------------------- | ---------------------------- |
|
||||
| Division, branch, customer, product | **Settings → Data** |
|
||||
| Salesperson | **Sales → Data → Employees** |
|
||||
|
||||
If a menu is missing, ask an administrator to give your privilege **View** (and **Update** for status buttons) on that area.
|
||||
|
||||
---
|
||||
|
||||
## The usual flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph sales [Sales]
|
||||
R[Sales Request]
|
||||
O[Sales Order]
|
||||
P[Process]
|
||||
I[Sales Invoice]
|
||||
PAY[Sales Payment]
|
||||
end
|
||||
subgraph logistics [Logistics]
|
||||
PS[Packing Slip]
|
||||
C[Complete]
|
||||
end
|
||||
R -.->|optional| O
|
||||
O --> P
|
||||
P --> I
|
||||
P -->|unless you skip it| PS
|
||||
PS --> C
|
||||
I --> PAY
|
||||
```
|
||||
|
||||
1. Optionally create and approve a **sales request**.
|
||||
2. Create a **sales order** (from the request, or from scratch).
|
||||
3. Open the draft order and click **Process**. Leave **Generate packing slip** checked unless you do not need delivery paperwork. Processing creates a **sales invoice**, and a **packing slip** unless you skip it.
|
||||
4. If there is a packing slip, open **Logistics → Packing slips**, then **Complete** and enter how much was delivered.
|
||||
5. Open the invoice and click **Create Sales Payment**. **Submit** the payment, then **Approve** it (or **Reject** / **Rollback**).
|
||||
|
||||
---
|
||||
|
||||
## Sales request
|
||||
|
||||
**Menu:** Sales → Activities → Sales Requests
|
||||
|
||||
A request is optional. You can go straight to a sales order.
|
||||
|
||||
1. Click **Create**. Fill in date, salesperson, branch, division, customer, address, and at least one product with a quantity. Product price and document code are optional. Save.
|
||||
2. While the status is **Draft**, click **Submit** to send it for approval. You can also use **Change status** to mark it **Rejected**.
|
||||
3. While it is **Pending**, click **Approve** or **Reject**. **Change status** can send it back to **Draft**.
|
||||
4. After it is **Approved**, there is nothing more to do on the request itself.
|
||||
5. On the request detail page, click **Create Sales Order**. The order form opens with the request’s customer, location, and products already filled in.
|
||||
|
||||
---
|
||||
|
||||
## Sales order
|
||||
|
||||
**Menu:** Sales → Activities → Sales Orders
|
||||
|
||||
1. Click **Create**. Fill in the same kind of details as a request. On a new order you can pick a sales request under **Source**; that copies the request into the form. Save. A new order starts as **Draft**.
|
||||
2. On a **Draft** order, click **Process**.
|
||||
- You will see: _Processing creates a sales invoice and, unless skipped, a packing slip._
|
||||
- **Generate packing slip** is checked by default. Uncheck it if you do not want a packing slip.
|
||||
3. On **Draft** or **Processed**, you can **Cancel**. There is no **Complete** button on orders. If the order later shows **Completed**, that was not something you clicked on this screen.
|
||||
4. On the order detail page you can see linked packing slips and invoices, and click **Create Sales Invoice**.
|
||||
|
||||
You can select several orders and Process or Cancel them together, as long as they are all in a status that allows that action.
|
||||
|
||||
---
|
||||
|
||||
## Packing slip
|
||||
|
||||
**Menu:** Logistics → Activities → Packing slips
|
||||
|
||||
Packing slips live under **Logistics**, not Sales. You can also create one by hand and link it to a sales order.
|
||||
|
||||
1. If the status is **Draft**, use **Change status** to move it to **Processed**, or **Cancel** it. There is no **Process** button here.
|
||||
2. When it is **Processed**, click **Complete**. Enter the **delivered quantity** for each product. Remaining quantity opens a **new packing slip**.
|
||||
3. You can **Cancel** a slip while it is **Draft** or **Processed**.
|
||||
|
||||
---
|
||||
|
||||
## Sales invoice
|
||||
|
||||
**Menu:** Sales → Activities → Sales Invoices
|
||||
|
||||
Invoices appear when you **Process** an order. You can also create one yourself, optionally from an order or a packing slip.
|
||||
|
||||
On the invoice, the only status button is **Cancel** (while the invoice is **Draft**, **Processed**, or **Partial**). You cannot choose Processed, Partial, or Completed yourself. Those labels can still show on the invoice.
|
||||
|
||||
On the invoice detail page, click **Create Sales Payment**. The payment form opens with that invoice and its remaining balance.
|
||||
|
||||
---
|
||||
|
||||
## Sales plan
|
||||
|
||||
**Menu:** Sales → Activities → Sales Plans
|
||||
|
||||
When you create or edit a **sales** plan, you can attach one or more invoices. Saving the plan does not change the invoice status.
|
||||
|
||||
(Logistics plans attach packing slips instead.)
|
||||
|
||||
---
|
||||
|
||||
## Sales payment
|
||||
|
||||
**Menu:** Sales → Activities → Sales Payments
|
||||
|
||||
1. Click **Create**. Enter the date and at least one line: an invoice and an amount. Images and notes are optional. Save. A new payment starts as **Draft**.
|
||||
2. Click **Submit** to move it to **Pending**. You can also use **Change status** to mark it **Rejected**.
|
||||
3. While it is **Pending**, click **Approve**, **Reject**, or **Rollback** (back to **Draft**).
|
||||
4. After **Approved**, you cannot change the status further. A **Rejected** payment can be sent back to **Draft** with **Change status**.
|
||||
|
||||
Approving a payment does not change the invoice status from this screen. Refresh the invoice if you need to see an updated balance.
|
||||
|
||||
---
|
||||
|
||||
## What you can do at each status
|
||||
|
||||
**Buttons** are the labelled actions on the list or detail page. **Change status** is a separate dialog that lists the statuses you may pick. Invoices do not have Change status.
|
||||
|
||||
### Sales request and sales payment
|
||||
|
||||
Both use Draft → Pending → Approved or Rejected.
|
||||
|
||||
| Current status | Buttons | Change status |
|
||||
| -------------- | ------------------------------------------------------------ | ------------------------- |
|
||||
| Draft | Submit | Rejected |
|
||||
| Pending | Approve, Reject. Payments also have Rollback (back to Draft) | Draft, Approved, Rejected |
|
||||
| Approved | — | — |
|
||||
| Rejected | — | Draft |
|
||||
|
||||
### Sales order
|
||||
|
||||
| Current status | Buttons | Change status |
|
||||
| -------------- | --------------- | -------------------- |
|
||||
| Draft | Process, Cancel | Processed, Cancelled |
|
||||
| Processed | Cancel | Cancelled |
|
||||
| Completed | — | — |
|
||||
| Cancelled | — | — |
|
||||
|
||||
**Process** always opens the packing-slip checkbox dialog (not Change status).
|
||||
|
||||
### Packing slip
|
||||
|
||||
| Current status | Buttons | Change status |
|
||||
| -------------- | ---------------- | -------------------- |
|
||||
| Draft | Cancel | Processed, Cancelled |
|
||||
| Processed | Complete, Cancel | Completed, Cancelled |
|
||||
| Completed | — | — |
|
||||
| Cancelled | — | — |
|
||||
|
||||
**Complete** always asks for delivered quantities.
|
||||
|
||||
### Sales invoice
|
||||
|
||||
You may see Draft, Processed, Partial, Completed, or Cancelled.
|
||||
|
||||
| Current status | What you can do |
|
||||
| ---------------------------- | ----------------- |
|
||||
| Draft, Processed, or Partial | Cancel |
|
||||
| Completed | No status buttons |
|
||||
| Cancelled | No status buttons |
|
||||
|
||||
---
|
||||
|
||||
## What you should not expect to click
|
||||
|
||||
These are not buttons on the screens:
|
||||
|
||||
- **Complete** on a sales order
|
||||
- Setting an invoice to Processed, Partial, or Completed yourself
|
||||
- Changing an invoice’s status by putting it on a sales plan
|
||||
- Changing an invoice’s status by approving a payment
|
||||
- Cancelling an order, packing slip, or invoice in one click for all related documents — **Cancel** applies only to the document you are looking at
|
||||
|
||||
If a related document’s status changes after you act, open that document again to see the latest value.
|
||||
This guide moved to [Sales workflow](/user/web/sales-workflow).
|
||||
|
||||
@@ -8,12 +8,12 @@ Use the sidebar to open **Sales** for day-to-day selling, **Logistics** for pack
|
||||
|
||||
## Where to find things
|
||||
|
||||
| Area | Menu | What you do there |
|
||||
| ------------ | ---------------------- | ------------------------------------------------- |
|
||||
| Sales | Sales → Activities | Requests, orders, invoices, payments, visit plans |
|
||||
| Logistics | Logistics → Activities | Packing slips, delivery plans |
|
||||
| Company data | Settings → Data | Divisions, branches, customers, products |
|
||||
| Access | Settings → User | Users and privileges |
|
||||
| Area | Menu | What you do there |
|
||||
| --- | --- | --- |
|
||||
| Sales | Sales → Activities | Requests, orders, invoices, payments, visit plans |
|
||||
| Logistics | Logistics → Activities | Packing slips, delivery plans |
|
||||
| Company data | Settings → Data | Divisions, branches, customers, products |
|
||||
| Access | Settings → User | Users and privileges |
|
||||
|
||||
If a menu item is missing, your privilege does not include **View** for that area.
|
||||
|
||||
@@ -21,4 +21,11 @@ If a menu item is missing, your privilege does not include **View** for that are
|
||||
|
||||
## Guides
|
||||
|
||||
- [Sales workflow](/apps/web/SALES_WORKFLOW) — from a sales request or order through packing, invoicing, and payment
|
||||
User guides now live under **User guides** in the sidebar:
|
||||
|
||||
- [Web overview](/user/web/)
|
||||
- [Set up your company](/user/web/setup)
|
||||
- [Sales workflow](/user/web/sales-workflow)
|
||||
- [Plans and visits](/user/web/plans)
|
||||
- [Logistics](/user/web/logistics)
|
||||
- [Timeline](/user/web/timeline)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: TrackGo
|
||||
text: Panduan penjualan dan logistik
|
||||
tagline: Cara operator, sales, dan driver memakai TrackGo di web dan di mobile.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Panduan pengguna
|
||||
link: /id/user/
|
||||
- theme: alt
|
||||
text: Web
|
||||
link: /id/user/web/
|
||||
|
||||
features:
|
||||
- title: Web
|
||||
details: Siapkan data perusahaan, pengguna, rencana, faktur, pembayaran, surat jalan, dan riwayat aktivitas.
|
||||
link: /id/user/web/
|
||||
linkText: Panduan web
|
||||
- title: Mobile
|
||||
details: Check-in di cabang, ikuti rencana hari ini, kunjungi pelanggan, dan catat pembayaran di lapangan.
|
||||
link: /id/user/mobile/
|
||||
linkText: Panduan mobile
|
||||
- title: Penjualan
|
||||
details: Dari permintaan atau pesanan penjualan sampai faktur dan pembayaran.
|
||||
link: /id/user/web/sales-workflow
|
||||
linkText: Alur penjualan
|
||||
- title: Rencana
|
||||
details: Buat rencana harian sekali jalan, atau generate rencana dari siklus kunjungan mingguan.
|
||||
link: /id/user/web/plans
|
||||
linkText: Rencana dan kunjungan
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { ShoppingCart, Truck, Smartphone, MapPin } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
<div class="custom-divider"></div>
|
||||
<div class="bento-container">
|
||||
<div class="bento-header">
|
||||
<h2>Cara TrackGo dipakai</h2>
|
||||
<p>Operator menyiapkan data master dan rencana harian di web. Sales dan driver check-in di mobile, lalu mengerjakan rencana: mengunjungi pelanggan, menagih faktur, atau menyelesaikan pengiriman.</p>
|
||||
</div>
|
||||
<div class="bento-grid">
|
||||
<div class="bento-card">
|
||||
<div class="bento-icon">
|
||||
<ShoppingCart :size="24" />
|
||||
</div>
|
||||
<h3>Penjualan di web</h3>
|
||||
<p>Buat permintaan, pesanan, faktur, dan pembayaran. Lampirkan faktur ke rencana penjualan agar tim lapangan tahu apa yang harus ditagih.</p>
|
||||
</div>
|
||||
<div class="bento-card">
|
||||
<div class="bento-icon">
|
||||
<Truck :size="24" />
|
||||
</div>
|
||||
<h3>Logistik di web</h3>
|
||||
<p>Kelola surat jalan dan rencana pengiriman. Lengkapi kuantitas terkirim agar pesanan bisa selesai.</p>
|
||||
</div>
|
||||
<div class="bento-card">
|
||||
<div class="bento-icon">
|
||||
<Smartphone :size="24" />
|
||||
</div>
|
||||
<h3>Kerja lapangan di mobile</h3>
|
||||
<p>Clock in di cabang awal rencana, buka destinasi hari ini, check-in ke pelanggan, lalu catat pembayaran.</p>
|
||||
</div>
|
||||
<div class="bento-card">
|
||||
<div class="bento-icon">
|
||||
<MapPin :size="24" />
|
||||
</div>
|
||||
<h3>Riwayat aktivitas</h3>
|
||||
<p>Tinjau jejak dan peristiwa lapangan hari itu di Timeline. Streaming peta langsung di web masih Segera hadir.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
# Apa itu TrackGo
|
||||
|
||||
TrackGo adalah platform penjualan dan logistik. Staf kantor memakai aplikasi **web**. Sales, driver, dan crew bekerja dari aplikasi **mobile**.
|
||||
|
||||
Siapkan dulu perusahaan, orang, produk, dan pelanggan. Setelah itu rencanakan kunjungan harian, lampirkan faktur atau surat jalan, lalu ikuti aktivitas lapangan.
|
||||
|
||||
## Siapa yang memakai
|
||||
|
||||
| Peran | Tempat kerja | Tugas umum |
|
||||
| --- | --- | --- |
|
||||
| Operator / admin | Web | Data perusahaan, pengguna, hak akses, rencana, dokumen |
|
||||
| Sales | Web dan mobile | Permintaan, pesanan, faktur, pembayaran, kunjungan pelanggan |
|
||||
| Driver / crew | Web (rencana) dan mobile | Rencana pengiriman, surat jalan, pemberhentian pelanggan |
|
||||
|
||||
Menu yang bisa dibuka tergantung **hak akses** yang ditetapkan ke pengguna. Jika menu tidak muncul, minta administrator memberi izin **Lihat** untuk area itu.
|
||||
|
||||
## Alur biasa
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
actor Operator
|
||||
actor Field as "Sales atau driver"
|
||||
|
||||
Operator -> Web: Siapkan perusahaan, pengguna, dan data master
|
||||
Operator -> Web: Buat siklus atau rencana sekali jalan
|
||||
Operator -> Web: Lampirkan faktur atau surat jalan
|
||||
Field -> Mobile: Clock in di cabang awal rencana
|
||||
Field -> Mobile: Kunjungi pelanggan di rencana
|
||||
Field -> Mobile: Tagih pembayaran atau ikuti tugas packing
|
||||
Operator -> Web: Tinjau dokumen dan Timeline
|
||||
@enduml
|
||||
```
|
||||
|
||||
1. Tambahkan pengaturan perusahaan, cabang, divisi, pelanggan, produk, dan karyawan.
|
||||
2. Buat pengguna dan hak akses agar setiap orang hanya melihat yang dibutuhkan.
|
||||
3. Buat **siklus mingguan** (berulang) atau **rencana sekali jalan** untuk satu tanggal. Lampirkan faktur (penjualan) atau surat jalan (logistik).
|
||||
4. Tim lapangan check-in di cabang lewat mobile, lalu mengerjakan rencana.
|
||||
5. Sales mencatat pembayaran di mobile atau web. Driver menyelesaikan surat jalan di web.
|
||||
|
||||
## Dokumen penjualan
|
||||
|
||||
Alur penjualan:
|
||||
|
||||
**Permintaan penjualan (opsional) → Pesanan penjualan → Faktur → Pembayaran**
|
||||
|
||||
Memproses pesanan juga dapat membuat surat jalan untuk pengiriman. Detail: [Alur penjualan](/id/user/web/sales-workflow).
|
||||
|
||||
## Web dan mobile
|
||||
|
||||
- [Web](/id/user/web/) — penyiapan, rencana, penjualan, logistik, timeline, impor CSV
|
||||
- [Mobile](/id/user/mobile/) — masuk, check-in cabang, rencana hari ini, pelanggan, pembayaran
|
||||
|
||||
## Segera hadir
|
||||
|
||||
Hal berikut belum ada di produk:
|
||||
|
||||
- **Dasbor** web (menu ada; halamannya masih placeholder)
|
||||
- Posisi **langsung** di peta web (Timeline memutar ulang rekaman satu hari)
|
||||
- Pembaruan **status surat jalan** di mobile (tugas logistik hanya tercantum, tidak diubah di ponsel)
|
||||
- Lupa kata sandi, ingat saya, dan daftar akun di layar masuk
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { MapPin } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Check-in
|
||||
|
||||
<p class="doc-lead"><MapPin :size="20" class="doc-icon" /> Clock in di cabang awal rencana sebelum kerja lapangan</p>
|
||||
|
||||
Sales dan driver **clock in di cabang awal rencana** sebelum mulai kerja lapangan. Aplikasi tidak memblokir penelusuran Beranda, Rencana, atau Pelanggan tanpa check-in, tetapi buat pelanggan, check-in kunjungan, dokumen penjualan, dan pembayaran tetap nonaktif sampai Anda bertugas.
|
||||
|
||||
## Sebelum clock in
|
||||
|
||||
Anda membutuhkan **rencana hari ini** dengan cabang awal. Jika tidak ada rencana, Clock In tidak tersedia dan layar menjelaskan tidak ada rencana hari ini. Operator harus [membuat atau men-generate rencana](/id/user/web/plans) di web.
|
||||
|
||||
## Clock in dan clock out
|
||||
|
||||
1. Dari Beranda, ketuk **Check-in sekarang** jika muncul **Anda belum check-in**, atau buka layar check-in.
|
||||
2. Pastikan lokasi di peta (OpenStreetMap). Pakai recenter jika perlu.
|
||||
3. Ketuk **Clock In Sekarang**. Aplikasi mengirim koordinat GPS untuk **cabang awal** rencana.
|
||||
4. Saat bertugas, aplikasi merekam jejak lokasi di latar belakang (interval berasal dari pengaturan GPS perusahaan).
|
||||
5. Ketuk **Clock Out** saat shift selesai. Pelacakan berhenti.
|
||||
|
||||
Layar menampilkan nama, tanggal, koordinat, serta waktu clock-in / clock-out.
|
||||
|
||||
Lingkaran di peta hanya panduan visual. Tombol Clock In aktif jika rencana ada dan Anda belum check-in — tidak diblokir karena berdiri di luar lingkaran itu.
|
||||
|
||||
## Setelah check-in
|
||||
|
||||
Selama kehadiran cabang terbuka:
|
||||
|
||||
- Anda dapat membuat pelanggan
|
||||
- Anda dapat check-in ke pelanggan pada kunjungan
|
||||
- Aksi penjualan (permintaan, pesanan, pembayaran) juga membutuhkan check-in **kunjungan pelanggan** untuk pelanggan itu
|
||||
|
||||
Jika GPS menjauh terlalu jauh dari kunjungan pelanggan, Beranda dapat menampilkan peringatan checkout agar Anda check-out dari kunjungan.
|
||||
|
||||
## Segera hadir
|
||||
|
||||
- Check-in lewat **NFC** atau **QR** (layar selalu memakai GPS)
|
||||
- Menegakkan radius check-in sebelum tombol Clock In diaktifkan
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Kerja harian
|
||||
|
||||
<p class="doc-lead"><Calendar :size="20" class="doc-icon" /> Beranda, Rencana, dan Pelanggan setelah Anda bertugas</p>
|
||||
|
||||
Setelah [check-in cabang](/id/user/mobile/check-in), pakai Beranda, Rencana, dan Pelanggan untuk mengerjakan hari itu.
|
||||
|
||||
## Beranda
|
||||
|
||||
Beranda punya dua tab: **Rencana** dan **Timeline**.
|
||||
|
||||
**Tab Rencana**
|
||||
|
||||
- Sapaan dan jabatan (Sales, Driver, atau Crew)
|
||||
- Banner jika Anda belum check-in di cabang
|
||||
- Destinasi hari ini — **Buka** memulai kunjungan pelanggan
|
||||
- Kartu kunjungan aktif dengan checkout
|
||||
- **Tugas Hari Ini** — faktur (sales) atau kode surat jalan (driver/crew)
|
||||
|
||||
**Tab Timeline**
|
||||
|
||||
Peristiwa lapangan hari ini dikelompokkan per pelanggan (check-in/out, dokumen penjualan, pelanggan baru). Tarik ke bawah untuk menyegarkan.
|
||||
|
||||
Keluar ada di menu avatar.
|
||||
|
||||
### Segera hadir di Beranda
|
||||
|
||||
- Total **kinerja** kerja (jumlah pelanggan / faktur / pembayaran / packing tidak ditampilkan di layar)
|
||||
- Ikon Pesan dan Notifikasi belum berfungsi
|
||||
|
||||
## Rencana
|
||||
|
||||
Tab **Rencana** menampilkan destinasi hari ini. Buka pemberhentian untuk ke layar kunjungan pelanggan.
|
||||
|
||||
Jika rencana ada, pakai **Tambah pelanggan ke rencana**:
|
||||
|
||||
1. Cari pelanggan yang belum ada di rencana.
|
||||
2. Untuk rencana **penjualan**, Anda dapat memilih faktur belum lunas untuk dilampirkan.
|
||||
3. Konfirmasi **Tambah ke rencana**.
|
||||
|
||||
Anda tidak dapat membuat rencana harian baru di ponsel — hanya menambah ke rencana yang sudah ada.
|
||||
|
||||
### Segera hadir di Rencana
|
||||
|
||||
- Memilih **surat jalan** saat menambah pelanggan ke rencana logistik
|
||||
|
||||
## Pelanggan
|
||||
|
||||
Cari dan urutkan daftar menurut **Nama**, **Kode**, atau **Jarak**. Ketuk baris untuk profil.
|
||||
|
||||
**Buat** (tombol FAB) hanya tersedia setelah check-in cabang. Formulir mencakup kode, nama, telepon, NFC ID (diketik), alamat, lokasi peta, dan kontak opsional.
|
||||
|
||||
### Kunjungi pelanggan
|
||||
|
||||
Buka destinasi dari rencana, atau buka rute kunjungan dari profil.
|
||||
|
||||
1. Selesaikan **Check-in pelanggan (GPS)** ketika aksi penjualan terkunci.
|
||||
2. Kemudian Anda dapat memakai **Aksi penjualan**.
|
||||
|
||||
Tanpa check-in cabang Anda melihat **Check-in cabang diperlukan**. Tanpa check-in kunjungan untuk pelanggan itu, Anda diminta check-in ke pelanggan.
|
||||
|
||||
### Permintaan dan pesanan penjualan
|
||||
|
||||
**Buat permintaan penjualan** dan **Buat pesanan penjualan (tanpa packing)** mengirim dokumen dengan **produk pertama yang dimuat** dan kuantitas **1**. Tidak ada pemilih produk atau keranjang di ponsel.
|
||||
|
||||
Untuk baris item lengkap, buat dokumen di [web](/id/user/web/sales-workflow).
|
||||
|
||||
### Pembayaran
|
||||
|
||||
Faktur belum lunas di daftar profil/kunjungan punya **Buat pembayaran**.
|
||||
|
||||
1. Pastikan kode faktur, status, dan saldo.
|
||||
2. Isi **jumlah** (dimulai dari sisa saldo) dan **catatan** opsional.
|
||||
3. Catat pembayaran. GPS dilampirkan jika tersedia.
|
||||
|
||||
Jumlah harus lebih dari nol dan tidak melebihi saldo. Aturan check-in cabang + kunjungan pelanggan yang sama berlaku.
|
||||
|
||||
## Yang dapat dilakukan driver hari ini
|
||||
|
||||
Driver dan crew mengikuti rencana logistik dan melihat kode surat jalan di Beranda. **Mengubah status surat jalan di mobile masih Segera hadir** — selesaikan surat jalan di [web](/id/user/web/logistics).
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { Smartphone } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Ringkasan mobile
|
||||
|
||||
<p class="doc-lead"><Smartphone :size="20" class="doc-icon" /> Aplikasi lapangan untuk sales, driver, dan crew</p>
|
||||
|
||||
Aplikasi mobile TrackGo untuk **sales**, **driver**, dan **crew** di lapangan. Tab bawah: **Beranda**, **Rencana**, dan **Pelanggan**. Check-in cabang adalah layar terpisah.
|
||||
|
||||
Jabatan karyawan menentukan rencana yang dimuat:
|
||||
|
||||
| Jabatan | Jenis rencana | Tugas hari ini di Beranda |
|
||||
| --- | --- | --- |
|
||||
| Sales | Penjualan | Faktur pada rencana hari ini |
|
||||
| Driver atau crew | Logistik | Kode surat jalan pada rencana hari ini |
|
||||
|
||||
Jika akun belum tertaut ke karyawan, Beranda dan Rencana menjelaskan bahwa profil belum tertaut.
|
||||
|
||||
## Masuk
|
||||
|
||||
1. Buka aplikasi.
|
||||
2. Masukkan **ID Karyawan** dan **Kata Sandi**.
|
||||
3. Ketuk **Masuk**.
|
||||
|
||||
Aturan username: 3–32 karakter. Kata sandi: 8–72 karakter.
|
||||
|
||||
## Hari kerja biasa
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
start
|
||||
:Masuk;
|
||||
:Buka Beranda atau Check-in;
|
||||
:Clock in di cabang awal rencana;
|
||||
:Buka rencana hari ini;
|
||||
:Check-in ke pelanggan;
|
||||
:Catat pembayaran atau buat dokumen penjualan;
|
||||
:Clock out kunjungan dan cabang;
|
||||
stop
|
||||
@enduml
|
||||
```
|
||||
|
||||
1. [Check-in](/id/user/mobile/check-in) di cabang (wajib sebelum sebagian besar aksi lapangan).
|
||||
2. [Kerja harian](/id/user/mobile/daily-work) — rencana, pelanggan, kunjungan, pembayaran, timeline Beranda.
|
||||
|
||||
## Segera hadir di layar masuk
|
||||
|
||||
Kontrol ini terlihat tetapi belum berfungsi:
|
||||
|
||||
- Ingat Saya
|
||||
- Lupa Kata Sandi
|
||||
- Masuk dengan Email atau Telepon
|
||||
- Daftar di Sini
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { FileText } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Impor CSV
|
||||
|
||||
<p class="doc-lead"><FileText :size="20" class="doc-icon" /> Impor di daftar dokumen jika hak akses mencakup Impor</p>
|
||||
|
||||
Anda dapat memuat banyak dokumen dari berkas **.csv** di halaman indeks berikut:
|
||||
|
||||
| Dokumen | Menu |
|
||||
| --- | --- |
|
||||
| Permintaan penjualan | Penjualan → Aktivitas → Permintaan Penjualan |
|
||||
| Pesanan penjualan | Penjualan → Aktivitas → Pesanan Penjualan |
|
||||
| Faktur penjualan | Penjualan → Aktivitas → Faktur Penjualan |
|
||||
| Pembayaran penjualan | Penjualan → Aktivitas → Pembayaran Penjualan |
|
||||
| Surat jalan | Logistik → Aktivitas → Surat Jalan |
|
||||
|
||||
## Cara impor
|
||||
|
||||
1. Buka daftar.
|
||||
2. Klik **Import CSV** (tersembunyi jika Anda tidak punya **Impor** pada area itu).
|
||||
3. Pilih berkas `.csv` dan konfirmasi.
|
||||
|
||||
Tidak ada wizard impor terpisah selain unggah berkas.
|
||||
|
||||
## Yang tidak dapat diimpor di sini
|
||||
|
||||
Data master (cabang, pelanggan, produk), pengguna, hak akses, siklus, dan rencana **tidak** punya tombol Import CSV. Buat catatan itu di layar masing-masing, atau generate rencana dari siklus.
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { LayoutDashboard, MapPin, ShoppingCart, Truck, Settings } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Ringkasan web
|
||||
|
||||
<p class="doc-lead"><LayoutDashboard :size="20" class="doc-icon" /> Aplikasi browser dan desktop untuk operator, sales, dan logistik.</p>
|
||||
|
||||
Masuk dengan **Username** dan **Password** di halaman masuk. Bahasa bisa diganti antara English dan Bahasa Indonesia.
|
||||
|
||||
Setelah masuk, sidebar hanya menampilkan menu yang diizinkan hak akses Anda.
|
||||
|
||||
## Menu
|
||||
|
||||
| Menu | Ikon | Yang dikerjakan |
|
||||
| --- | --- | --- |
|
||||
| Dasbor | <LayoutDashboard :size="16" class="doc-icon" /> | Segera hadir |
|
||||
| Timeline | <MapPin :size="16" class="doc-icon" /> | Putar ulang jejak GPS dan aktivitas lapangan karyawan sales untuk satu hari |
|
||||
| Penjualan | <ShoppingCart :size="16" class="doc-icon" /> | Karyawan, siklus, permintaan, pesanan, faktur, pembayaran, rencana, laporan |
|
||||
| Logistik | <Truck :size="16" class="doc-icon" /> | Driver dan crew, siklus, surat jalan, rencana, laporan |
|
||||
| Pengaturan | <Settings :size="16" class="doc-icon" /> | Cabang, divisi, pelanggan, produk, pengaturan perusahaan, pengguna, hak akses |
|
||||
|
||||
## Urutan yang disarankan
|
||||
|
||||
1. [Siapkan perusahaan](/id/user/web/setup) — data master, pengguna, dan hak akses
|
||||
2. [Rencana dan kunjungan](/id/user/web/plans) — rencana sekali jalan dan siklus mingguan
|
||||
3. [Alur penjualan](/id/user/web/sales-workflow) — permintaan, pesanan, faktur, pembayaran
|
||||
4. [Logistik](/id/user/web/logistics) — surat jalan dan rencana pengiriman
|
||||
5. [Timeline](/id/user/web/timeline) — riwayat aktivitas di peta
|
||||
6. [Impor CSV](/id/user/web/import) — unggah dokumen sekaligus
|
||||
|
||||
## Laporan
|
||||
|
||||
**Penjualan → Laporan Penjualan** dan **Logistik → Laporan Logistik** menampilkan tabel laporan dari server. Jika belum dikonfigurasi, halaman menyatakan tidak ada laporan.
|
||||
|
||||
## Status dan izin
|
||||
|
||||
Sebagian besar data master memakai **Draft**, **Aktif**, dan **Diarsipkan**. Dokumen penjualan dan logistik punya status sendiri (lihat masing-masing panduan).
|
||||
|
||||
Tombol seperti Buat, Process, Submit, dan Approve hanya muncul jika hak akses Anda mencakup aksi itu (**Buat**, **Ubah**, **Hapus**, atau **Impor**).
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { Truck, Package, Calendar } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Logistik
|
||||
|
||||
<p class="doc-lead"><Truck :size="20" class="doc-icon" /> Menu logistik untuk driver, crew, surat jalan, dan rencana pengiriman</p>
|
||||
|
||||
Pakai Logistik jika Anda butuh dokumen pengiriman dan rute driver. Penjualan tetap memiliki permintaan, pesanan, faktur, dan pembayaran.
|
||||
|
||||
## Karyawan dan siklus
|
||||
|
||||
- **Logistik → Data → Karyawan** — jabatan **driver** dan **crew**
|
||||
- **Logistik → Data → Siklus Logistik** — templat kunjungan mingguan untuk karyawan itu
|
||||
|
||||
Generate rencana harian dari siklus dengan cara yang sama seperti penjualan. Lihat [Rencana dan kunjungan](/id/user/web/plans).
|
||||
|
||||
## Surat jalan
|
||||
|
||||
<p class="doc-lead"><Package :size="18" class="doc-icon" /> Logistik → Aktivitas → Surat Jalan</p>
|
||||
|
||||
Surat jalan dibuat ketika Anda **Process** pesanan penjualan dengan **Generate packing slip** tercentang. Anda juga dapat membuatnya sendiri dan menautkannya ke pesanan penjualan.
|
||||
|
||||
Kolom umum: kode, tanggal, pelanggan, pesanan penjualan, lokasi, produk, catatan.
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
start
|
||||
if (Status Draft?) then (ya)
|
||||
:Change status ke Processed\natau Cancel;
|
||||
endif
|
||||
if (Status Processed?) then (ya)
|
||||
:Complete;
|
||||
:Isi kuantitas terkirim per produk;
|
||||
if (Ada sisa kuantitas?) then (ya)
|
||||
:Surat jalan baru dibuat\nuntuk sisa;
|
||||
else (tidak)
|
||||
:Surat jalan Completed;
|
||||
endif
|
||||
endif
|
||||
stop
|
||||
@enduml
|
||||
```
|
||||
|
||||
1. Jika status **Draft**, pakai **Change status** untuk pindah ke **Processed**, atau **Cancel**. Tidak ada tombol **Process** di sini.
|
||||
2. Saat **Processed**, klik **Complete**. Isi **kuantitas terkirim** untuk setiap produk. Sisa kuantitas membuka **surat jalan baru**.
|
||||
3. Anda dapat **Cancel** surat jalan saat **Draft** atau **Processed**.
|
||||
|
||||
Ketika semua surat jalan terkait selesai, pesanan penjualan terkait dapat pindah ke **Completed**. Jika packing dilewati pada pesanan, pesanan tidak menunggu surat jalan.
|
||||
|
||||
### Aksi status
|
||||
|
||||
| Status saat ini | Tombol | Change status |
|
||||
| --- | --- | --- |
|
||||
| Draft | Cancel | Processed, Cancelled |
|
||||
| Processed | Complete, Cancel | Completed, Cancelled |
|
||||
| Completed | — | — |
|
||||
| Cancelled | — | — |
|
||||
|
||||
**Complete** selalu meminta kuantitas terkirim.
|
||||
|
||||
Anda dapat [impor surat jalan dari CSV](/id/user/web/import) jika hak akses mencakup **Impor**.
|
||||
|
||||
## Rencana logistik
|
||||
|
||||
<p class="doc-lead"><Calendar :size="18" class="doc-icon" /> Logistik → Aktivitas → Rencana Logistik</p>
|
||||
|
||||
Model rencana sama seperti penjualan: karyawan, tanggal, cabang awal/akhir, pelanggan. Lampirkan **surat jalan** bukan faktur.
|
||||
|
||||
Driver dan crew membuka rencana hari ini di [mobile](/id/user/mobile/daily-work). Beranda menampilkan kode surat jalan sebagai tugas hari ini.
|
||||
|
||||
## Segera hadir
|
||||
|
||||
**Memperbarui status surat jalan di mobile** belum tersedia. Driver melihat kode packing yang ditugaskan di Beranda; mereka menyelesaikan surat jalan di aplikasi web (atau operator yang mengerjakannya).
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { Calendar, Repeat } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Rencana dan kunjungan
|
||||
|
||||
<p class="doc-lead"><Calendar :size="20" class="doc-icon" /> Penjualan → Aktivitas → Rencana Penjualan, atau Logistik → Aktivitas → Rencana Logistik</p>
|
||||
|
||||
**Rencana** adalah pekerjaan satu karyawan untuk **satu tanggal**: cabang awal, cabang akhir, pelanggan yang dikunjungi, dan dokumen yang harus ditagih atau dikirim.
|
||||
|
||||
Tidak ada saklar pengulangan di formulir rencana. Pekerjaan berulang memakai **siklus**, lalu **Generate**.
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
start
|
||||
if (Perlu rute mingguan berulang?) then (ya)
|
||||
:Buat siklus dengan rute hari kerja;
|
||||
:Generate rencana untuk rentang tanggal;
|
||||
else (tidak)
|
||||
:Buat rencana sekali jalan untuk satu tanggal;
|
||||
endif
|
||||
:Lampirkan faktur (penjualan) atau surat jalan (logistik);
|
||||
:Tim lapangan membuka rencana di mobile;
|
||||
stop
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Rencana sekali jalan
|
||||
|
||||
**Deskripsi halaman buat di aplikasi:** "Buat rencana harian sekali jalan untuk karyawan."
|
||||
|
||||
1. Buka **Rencana Penjualan** atau **Rencana Logistik**.
|
||||
2. Klik **Buat**.
|
||||
3. Pilih **karyawan**, **tanggal** (hari ini atau setelahnya), **cabang awal**, dan **cabang akhir**.
|
||||
4. Tambahkan **pelanggan** sebagai destinasi (urutan berpengaruh pada rute).
|
||||
5. Setelah pelanggan dipilih:
|
||||
- Rencana penjualan: lampirkan **faktur** pelanggan itu
|
||||
- Rencana logistik: lampirkan **surat jalan** pelanggan itu
|
||||
6. Simpan. Status **Draft**, **Aktif**, atau **Diarsipkan** seperti data master lain.
|
||||
|
||||
Anda dapat meninjau pratinjau pelanggan dan dokumen terlampir di formulir.
|
||||
|
||||
Menyimpan rencana penjualan **tidak** mengubah status faktur.
|
||||
|
||||
## Siklus mingguan (berulang)
|
||||
|
||||
<p class="doc-lead"><Repeat :size="18" class="doc-icon" /> Penjualan → Data → Siklus Penjualan, atau Logistik → Data → Siklus Logistik</p>
|
||||
|
||||
Siklus adalah **templat kunjungan mingguan** untuk satu karyawan: nomor siklus dan, untuk setiap hari, apakah hari itu aktif, cabang awal/akhir, dan pelanggan.
|
||||
|
||||
1. Buat siklus untuk karyawan.
|
||||
2. Aktifkan hari kerja dan isi rutenya.
|
||||
3. Setel siklus ke **Aktif**.
|
||||
|
||||
## Generate rencana dari siklus
|
||||
|
||||
Di daftar rencana, klik **Generate**.
|
||||
|
||||
1. Pilih **karyawan**.
|
||||
2. Pilih tanggal **dari** dan **sampai**.
|
||||
3. Konfirmasi. Aplikasi membuat rencana harian dari siklus dan melaporkan berapa yang **dibuat** dan **dilewati** (misalnya jika rencana sudah ada).
|
||||
|
||||
## Siapa yang memakai rencana
|
||||
|
||||
- **Operator web** membuat dan men-generate rencana, lalu melampirkan dokumen.
|
||||
- **Sales dan driver di mobile** melihat rencana hari ini setelah rencana itu ada. Mereka dapat menambah pelanggan ke rencana yang sudah ada; mereka tidak dapat membuat rencana baru di ponsel.
|
||||
|
||||
Lihat [Kerja harian mobile](/id/user/mobile/daily-work).
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { ShoppingCart, ClipboardList, Box, Receipt, CreditCard } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Alur penjualan
|
||||
|
||||
<p class="doc-lead"><ShoppingCart :size="20" class="doc-icon" /> Penjualan → Aktivitas</p>
|
||||
|
||||
Cara menjalankan penjualan: dari permintaan atau pesanan, melalui packing dan faktur, sampai pembayaran.
|
||||
|
||||
Anda perlu izin untuk melihat setiap menu. Tombol status (Process, Complete, Submit, Approve, dan seterusnya) hanya muncul jika Anda boleh mengubah data.
|
||||
|
||||
## Sebelum mulai
|
||||
|
||||
Siapkan data yang dibutuhkan setiap dokumen penjualan:
|
||||
|
||||
| Yang dibutuhkan | Tempat membuat |
|
||||
| --- | --- |
|
||||
| Divisi, cabang, pelanggan, produk | [Pengaturan → Data](/id/user/web/setup) |
|
||||
| Sales | Penjualan → Data → Karyawan |
|
||||
|
||||
Jika menu tidak muncul, minta administrator memberi hak akses **Lihat** (dan **Ubah** untuk tombol status) pada area itu.
|
||||
|
||||
## Alur biasa
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
start
|
||||
:Permintaan penjualan (opsional);
|
||||
:Pesanan penjualan (Draft);
|
||||
:Process pesanan;
|
||||
fork
|
||||
:Faktur penjualan dibuat;
|
||||
fork again
|
||||
:Surat jalan dibuat\n(kecuali dilewati);
|
||||
end fork
|
||||
if (Ada surat jalan?) then (ya)
|
||||
:Complete kuantitas terkirim;
|
||||
endif
|
||||
:Buat pembayaran penjualan;
|
||||
:Submit lalu Approve;
|
||||
stop
|
||||
@enduml
|
||||
```
|
||||
|
||||
1. Opsional buat dan setujui **permintaan penjualan**.
|
||||
2. Buat **pesanan penjualan** (dari permintaan, atau dari awal).
|
||||
3. Buka pesanan Draft dan klik **Process**. Biarkan **Generate packing slip** tercentang kecuali Anda tidak butuh surat jalan. Process membuat **faktur penjualan**, dan **surat jalan** kecuali dilewati.
|
||||
4. Jika ada surat jalan, buka **Logistik → Surat Jalan**, lalu **Complete** dan isi berapa yang terkirim.
|
||||
5. Buka faktur dan klik **Create Sales Payment**. **Submit** pembayaran, lalu **Approve** (atau **Reject** / **Rollback**).
|
||||
|
||||
Anda juga dapat [impor CSV](/id/user/web/import) untuk permintaan, pesanan, faktur, dan pembayaran.
|
||||
|
||||
## Permintaan penjualan
|
||||
|
||||
<p class="doc-lead"><ClipboardList :size="18" class="doc-icon" /> Penjualan → Aktivitas → Permintaan Penjualan</p>
|
||||
|
||||
Permintaan bersifat opsional. Anda dapat langsung ke pesanan penjualan.
|
||||
|
||||
1. Klik **Buat**. Isi tanggal, sales, cabang, divisi, pelanggan, alamat, dan minimal satu produk beserta kuantitas. Harga produk dan kode dokumen opsional. Simpan.
|
||||
2. Saat status **Draft**, klik **Submit** untuk dikirim ke persetujuan. Anda juga dapat memakai **Change status** untuk menandai **Rejected**.
|
||||
3. Saat **Pending**, klik **Approve** atau **Reject**. **Change status** dapat mengembalikannya ke **Draft**.
|
||||
4. Setelah **Approved**, tidak ada langkah lagi pada permintaan itu sendiri.
|
||||
5. Di halaman detail permintaan, klik **Create Sales Order**. Formulir pesanan terbuka dengan pelanggan, lokasi, dan produk dari permintaan.
|
||||
|
||||
## Pesanan penjualan
|
||||
|
||||
<p class="doc-lead"><Box :size="18" class="doc-icon" /> Penjualan → Aktivitas → Pesanan Penjualan</p>
|
||||
|
||||
1. Klik **Buat**. Isi detail yang sama seperti permintaan. Pada pesanan baru Anda dapat memilih permintaan penjualan di **Source**; itu menyalin permintaan ke formulir. Simpan. Pesanan baru berstatus **Draft**.
|
||||
2. Pada pesanan **Draft**, klik **Process**.
|
||||
- Anda akan melihat: *Processing creates a sales invoice and, unless skipped, a packing slip.*
|
||||
- **Generate packing slip** tercentang secara default. Hapus centang jika tidak ingin surat jalan.
|
||||
3. Pada **Draft** atau **Processed**, Anda dapat **Cancel**. Tidak ada tombol **Complete** pada pesanan. Jika pesanan kemudian menampilkan **Completed**, itu bukan tombol yang Anda klik di layar ini.
|
||||
4. Di halaman detail pesanan Anda dapat melihat surat jalan dan faktur terkait, serta klik **Create Sales Invoice**.
|
||||
|
||||
Anda dapat memilih beberapa pesanan lalu Process atau Cancel bersama, selama semuanya dalam status yang mengizinkan aksi itu.
|
||||
|
||||
## Surat jalan
|
||||
|
||||
Surat jalan ada di **Logistik**, bukan Penjualan. Lihat [Logistik](/id/user/web/logistics).
|
||||
|
||||
## Faktur penjualan
|
||||
|
||||
<p class="doc-lead"><Receipt :size="18" class="doc-icon" /> Penjualan → Aktivitas → Faktur Penjualan</p>
|
||||
|
||||
Faktur muncul ketika Anda **Process** pesanan. Anda juga dapat membuatnya sendiri, opsional dari pesanan atau surat jalan.
|
||||
|
||||
Pada faktur, satu-satunya tombol status adalah **Cancel** (saat faktur **Draft**, **Processed**, atau **Partial**). Anda tidak dapat memilih Processed, Partial, atau Completed sendiri. Label itu tetap bisa tampil di faktur.
|
||||
|
||||
Di halaman detail faktur, klik **Create Sales Payment**. Formulir pembayaran terbuka dengan faktur itu dan sisa saldonya.
|
||||
|
||||
Anda dapat melampirkan faktur ke [rencana penjualan](/id/user/web/plans). Menyimpan rencana tidak mengubah status faktur.
|
||||
|
||||
## Pembayaran penjualan
|
||||
|
||||
<p class="doc-lead"><CreditCard :size="18" class="doc-icon" /> Penjualan → Aktivitas → Pembayaran Penjualan</p>
|
||||
|
||||
1. Klik **Buat**. Isi tanggal dan minimal satu baris: faktur dan jumlah. Gambar dan catatan opsional. Simpan. Pembayaran baru berstatus **Draft**.
|
||||
2. Klik **Submit** untuk pindah ke **Pending**. Anda juga dapat memakai **Change status** untuk menandai **Rejected**.
|
||||
3. Saat **Pending**, klik **Approve**, **Reject**, atau **Rollback** (kembali ke **Draft**).
|
||||
4. Setelah **Approved**, status tidak dapat diubah lagi. Pembayaran **Rejected** dapat dikembalikan ke **Draft** dengan **Change status**.
|
||||
|
||||
Menyetujui pembayaran tidak mengubah status faktur dari layar ini. Muat ulang faktur jika Anda perlu melihat saldo terbaru.
|
||||
|
||||
Tim lapangan juga dapat [mencatat pembayaran di mobile](/id/user/mobile/daily-work) dari faktur belum lunas.
|
||||
|
||||
## Yang dapat dilakukan di setiap status
|
||||
|
||||
**Tombol** adalah aksi berlabel di daftar atau halaman detail. **Change status** adalah dialog terpisah yang menampilkan status yang boleh dipilih. Faktur tidak punya Change status.
|
||||
|
||||
### Permintaan penjualan dan pembayaran penjualan
|
||||
|
||||
Keduanya memakai Draft → Pending → Approved atau Rejected.
|
||||
|
||||
| Status saat ini | Tombol | Change status |
|
||||
| --- | --- | --- |
|
||||
| Draft | Submit | Rejected |
|
||||
| Pending | Approve, Reject. Pembayaran juga punya Rollback (kembali ke Draft) | Draft, Approved, Rejected |
|
||||
| Approved | — | — |
|
||||
| Rejected | — | Draft |
|
||||
|
||||
### Pesanan penjualan
|
||||
|
||||
| Status saat ini | Tombol | Change status |
|
||||
| --- | --- | --- |
|
||||
| Draft | Process, Cancel | Processed, Cancelled |
|
||||
| Processed | Cancel | Cancelled |
|
||||
| Completed | — | — |
|
||||
| Cancelled | — | — |
|
||||
|
||||
**Process** selalu membuka dialog centang surat jalan (bukan Change status).
|
||||
|
||||
### Faktur penjualan
|
||||
|
||||
Anda mungkin melihat Draft, Processed, Partial, Completed, atau Cancelled.
|
||||
|
||||
| Status saat ini | Yang dapat dilakukan |
|
||||
| --- | --- |
|
||||
| Draft, Processed, atau Partial | Cancel |
|
||||
| Completed | Tidak ada tombol status |
|
||||
| Cancelled | Tidak ada tombol status |
|
||||
|
||||
## Yang tidak perlu diklik
|
||||
|
||||
Ini bukan tombol di layar:
|
||||
|
||||
- **Complete** pada pesanan penjualan
|
||||
- Mengatur faktur ke Processed, Partial, atau Completed sendiri
|
||||
- Mengubah status faktur dengan menaruhnya di rencana penjualan
|
||||
- Mengubah status faktur dengan menyetujui pembayaran
|
||||
- Membatalkan pesanan, surat jalan, atau faktur dalam satu klik untuk semua dokumen terkait — **Cancel** hanya berlaku pada dokumen yang sedang dibuka
|
||||
|
||||
Jika status dokumen terkait berubah setelah Anda bertindak, buka dokumen itu lagi untuk melihat nilai terbaru.
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { Settings, Users, Shield } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Siapkan perusahaan
|
||||
|
||||
<p class="doc-lead"><Settings :size="20" class="doc-icon" /> Pengaturan → Data dan Pengaturan → Pengguna</p>
|
||||
|
||||
Lakukan ini sebelum membuat rencana atau dokumen penjualan. Data yang dibutuhkan layar lain (cabang, divisi, pelanggan, produk, karyawan) harus sudah ada.
|
||||
|
||||
## Pengaturan perusahaan
|
||||
|
||||
**Menu:** Pengaturan → Data → Pengaturan perusahaan
|
||||
|
||||
Ini formulir tunggal, bukan daftar. Simpan:
|
||||
|
||||
| Kolom | Dipakai untuk |
|
||||
| --- | --- |
|
||||
| Tanggal awal siklus | Kapan siklus kunjungan mingguan mulai dihitung |
|
||||
| Radius check-in (meter) | Seberapa dekat tim lapangan saat check-in |
|
||||
| Interval GPS (detik) | Seberapa sering aplikasi mobile merekam jejak saat bertugas |
|
||||
| Radius peringatan checkout (meter) | Kapan mobile memperingatkan bahwa pengguna meninggalkan kunjungan pelanggan |
|
||||
|
||||
## Divisi, cabang, pelanggan, produk
|
||||
|
||||
**Menu:** Pengaturan → Data
|
||||
|
||||
Masing-masing berupa daftar dengan Buat, Detail, Ubah, dan Duplikat. Status: **Draft**, **Aktif**, atau **Diarsipkan**.
|
||||
|
||||
| Data | Kolom umum |
|
||||
| --- | --- |
|
||||
| Divisi | Nama, kode |
|
||||
| Cabang | Kode, nama, telepon, NFC ID, alamat, lokasi peta, divisi, hari dan jam kerja |
|
||||
| Pelanggan | Kode, nama, telepon, alamat, lokasi peta, NFC ID; kontak saat membuat |
|
||||
| Produk | Kode, nama, satuan, harga, merek |
|
||||
|
||||
Pilih lokasi di peta untuk cabang dan pelanggan (atau ketik lintang dan bujur).
|
||||
|
||||
## Karyawan
|
||||
|
||||
Karyawan dipisah menurut tujuan:
|
||||
|
||||
| Menu | Jabatan |
|
||||
| --- | --- |
|
||||
| Penjualan → Data → Karyawan | Sales |
|
||||
| Logistik → Data → Karyawan | Driver, crew |
|
||||
|
||||
Kolom: kode, nama, telepon, jabatan. Kemudian tautkan **pengguna** ke karyawan agar mobile tahu harus memuat rencana penjualan atau logistik.
|
||||
|
||||
## Hak akses
|
||||
|
||||
<p class="doc-lead"><Shield :size="18" class="doc-icon" /> Pengaturan → Pengguna → Hak Akses</p>
|
||||
|
||||
Hak akses adalah kumpulan izin bernama. Di formulir, isi **nama**, **kode**, dan matriks izin. Untuk setiap area Anda dapat mengizinkan:
|
||||
|
||||
- **Lihat** — tampilkan menu dan buka halaman
|
||||
- **Buat** — buat dan duplikat
|
||||
- **Ubah** — edit dan ubah status
|
||||
- **Hapus** — hapus
|
||||
- **Impor** — impor CSV pada daftar yang didukung
|
||||
|
||||
Tanpa **Lihat**, item itu disembunyikan dari sidebar.
|
||||
|
||||
## Pengguna
|
||||
|
||||
<p class="doc-lead"><Users :size="18" class="doc-icon" /> Pengaturan → Pengguna → Pengguna</p>
|
||||
|
||||
1. Klik **Buat**.
|
||||
2. Isi **username** dan **password** (wajib saat membuat; saat mengubah, kosongkan untuk tetap memakai kata sandi saat ini).
|
||||
3. Pilih **hak akses**.
|
||||
4. Opsional tautkan **karyawan** agar login lapangan terhubung ke penjualan atau logistik.
|
||||
|
||||
Aktifkan pengguna ketika mereka boleh masuk.
|
||||
|
||||
## Segera hadir
|
||||
|
||||
- **Ingat saya** di formulir masuk web tidak disimpan
|
||||
- **Lupa Kata Sandi** belum tersedia
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { MapPin } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Timeline
|
||||
|
||||
<p class="doc-lead"><MapPin :size="20" class="doc-icon" /> Timeline di sidebar</p>
|
||||
|
||||
Timeline menampilkan jejak GPS dan aktivitas lapangan **satu hari** untuk karyawan **sales**. Ini pemutaran ulang dari yang disimpan aplikasi mobile saat orang itu bertugas, bukan umpan langsung.
|
||||
|
||||
## Cara memakai
|
||||
|
||||
1. Buka **Timeline**.
|
||||
2. Pilih **Tanggal**.
|
||||
3. Opsional saring **Karyawan** (karyawan sales), atau biarkan semua karyawan.
|
||||
4. Cari aktivitas jika daftarnya panjang.
|
||||
5. Ganti tab **Di perjalanan** dan **Selesai**.
|
||||
6. Pakai **Playback** (Putar, Jeda, Waktu) untuk bergerak di sepanjang rute terekam. Overlay **Lokasi saat ini** adalah kursor playback, bukan titik GPS langsung.
|
||||
|
||||
Jika tidak ada data untuk hari itu, peta menampilkan tidak ada data timeline.
|
||||
|
||||
## Tampilan aktivitas
|
||||
|
||||
Daftar dapat mencakup:
|
||||
|
||||
- Check-in cabang / check-out cabang
|
||||
- Check-in pelanggan / check-out pelanggan
|
||||
- Sales order dibuat
|
||||
- Sales request dibuat
|
||||
- Pembayaran dibuat
|
||||
- Pelanggan dibuat
|
||||
|
||||
Jejak direkam di mobile setelah clock-in cabang, memakai **Interval GPS** di [pengaturan perusahaan](/id/user/web/setup).
|
||||
|
||||
## Segera hadir
|
||||
|
||||
- Pelacakan **langsung** sales atau driver di peta web
|
||||
- Timeline khusus **logistik / driver** (layar ini memuat karyawan sales)
|
||||
+45
-236
@@ -2,265 +2,74 @@
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: 'Frontend Architecture'
|
||||
text: 'Enterprise Monorepo'
|
||||
tagline: 'A scalable, standardized foundation for Web & Desktop applications. Built for performance, consistency, and velocity.'
|
||||
name: TrackGo
|
||||
text: Sales and logistics guides
|
||||
tagline: How operators, sales, and drivers use TrackGo on the web and on mobile.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get Started
|
||||
link: /setup
|
||||
text: User guides
|
||||
link: /user/
|
||||
- theme: alt
|
||||
text: View Architecture
|
||||
link: /overview
|
||||
text: Web
|
||||
link: /user/web/
|
||||
|
||||
features:
|
||||
- title: 🧩 App Layout Engine
|
||||
details: Core App Shell wrapping Mantine for rapid, consistent layout creation across all products.
|
||||
link: /packages/ui/CORE-APP-SHELL
|
||||
linkText: Explore App Shell
|
||||
- title: 📝 Form Primitives
|
||||
details: RHF + Zod + Mantine components designed for O(1) render cost and deep type safety.
|
||||
link: /packages/ui/FORM-COMPONENTS
|
||||
linkText: View Forms
|
||||
- title: 🖥️ Desktop Ecosystem
|
||||
details: Complete Electron integration with secure Inter-Process Communication (IPC) and Bridge Architecture.
|
||||
link: /apps/desktop/
|
||||
linkText: Desktop Docs
|
||||
- title: 🔌 Core Architecture
|
||||
details: Robust state management, Event Bus, first-class i18n, and unified Storage layers.
|
||||
link: /packages/core-api/
|
||||
linkText: Explore Core
|
||||
- title: Web
|
||||
details: Set up company data, users, plans, invoices, payments, packing slips, and activity history.
|
||||
link: /user/web/
|
||||
linkText: Web guides
|
||||
- title: Mobile
|
||||
details: Check in at a branch, follow today's plan, visit customers, and collect payments in the field.
|
||||
link: /user/mobile/
|
||||
linkText: Mobile guides
|
||||
- title: Sales
|
||||
details: Move from a sales request or order through invoicing and payment.
|
||||
link: /user/web/sales-workflow
|
||||
linkText: Sales workflow
|
||||
- title: Plans
|
||||
details: Create a one-off daily plan, or generate plans from weekly visit cycles.
|
||||
link: /user/web/plans
|
||||
linkText: Plans and visits
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { ShoppingCart, Truck, Smartphone, MapPin } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
<div class="custom-divider"></div>
|
||||
<div class="bento-container">
|
||||
<div class="bento-header">
|
||||
<h2>Why this Monorepo Exists</h2>
|
||||
<p>Building high-end enterprise applications requires strict consistency, shared tooling, and well-defined boundaries. This monorepo serves as the absolute source of truth for our frontend ecosystem.</p>
|
||||
<h2>How TrackGo is used</h2>
|
||||
<p>Operators prepare master data and daily plans on the web. Sales and drivers check in on mobile, then work the plan: visit customers, collect invoices, or complete deliveries.</p>
|
||||
</div>
|
||||
<div class="bento-grid">
|
||||
<div class="bento-card">
|
||||
<div class="bento-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>
|
||||
<ShoppingCart :size="24" />
|
||||
</div>
|
||||
<h3>Unified Tooling</h3>
|
||||
<p>By enforcing strict linting, formatting, and building patterns at the root (powered by Turborepo), we ensure that every application in the ecosystem adheres to our high standards without duplicate configuration.</p>
|
||||
<h3>Sales on the web</h3>
|
||||
<p>Create requests, orders, invoices, and payments. Attach invoices to a sales plan so field staff know what to collect.</p>
|
||||
</div>
|
||||
<div class="bento-card">
|
||||
<div class="bento-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>
|
||||
<Truck :size="24" />
|
||||
</div>
|
||||
<h3>Shared Domain Logic</h3>
|
||||
<p>The separation of UI components and Core logic guarantees that our web and desktop applications all share the exact same state management and event-driven architecture, drastically reducing cognitive load.</p>
|
||||
<h3>Logistics on the web</h3>
|
||||
<p>Manage packing slips and delivery plans. Complete delivered quantities so orders can finish.</p>
|
||||
</div>
|
||||
<div class="bento-card">
|
||||
<div class="bento-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
|
||||
<Smartphone :size="24" />
|
||||
</div>
|
||||
<h3>Accelerated Velocity</h3>
|
||||
<p>With a rich library of 22+ advanced Form components, pre-configured Vite/TypeScript environments, and an enterprise App Shell, teams can stop reinventing the wheel and start building product features on day one.</p>
|
||||
<h3>Field work on mobile</h3>
|
||||
<p>Clock in at the plan start branch, open today's destinations, check in to a customer, and record a payment.</p>
|
||||
</div>
|
||||
<div class="bento-card">
|
||||
<div class="bento-icon">
|
||||
<MapPin :size="24" />
|
||||
</div>
|
||||
<h3>Activity history</h3>
|
||||
<p>Review a day's footprints and field events on Timeline. Live map streaming on the web is Coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
SECTION 1: GLOBAL & HERO BACKGROUNDS (Masked Dot Grid & Top Glow)
|
||||
========================================================================== */
|
||||
:global(.VPHome) {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Efek Brand Glow memancar halus dari atas tengah (Area Hero) */
|
||||
:global(.VPHome::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100vw;
|
||||
max-width: 1200px;
|
||||
height: 800px;
|
||||
background: radial-gradient(ellipse at top, var(--vp-c-brand-soft) 0%, transparent 70%);
|
||||
filter: blur(100px);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Efek Dot Grid yang dibatasi hanya setinggi Hero dan memudar ke bawah */
|
||||
:global(.VPHome::after) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
background-image: radial-gradient(var(--vp-c-divider) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
background-position: top center;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
SECTION 2: BENTO CONTAINER & HEADER (Structured Minimalism)
|
||||
========================================================================== */
|
||||
/* Divider dengan jarak vertikal yang presisi dan lega */
|
||||
.custom-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--vp-c-divider);
|
||||
margin: 80px 0 64px 0;
|
||||
}
|
||||
|
||||
.bento-container {
|
||||
max-width: 1152px;
|
||||
margin: auto;
|
||||
padding: 0 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bento-header {
|
||||
text-align: center;
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
|
||||
.bento-header h2 {
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: var(--vp-c-text-1);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.bento-header p {
|
||||
font-size: 1.15em;
|
||||
color: var(--vp-c-text-2);
|
||||
max-width: 760px; /* Memaksa teks membungkus menjadi 2 baris yang ideal */
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
SECTION 2: BENTO GRID & CARD DESIGN (Premium Glassmorphism)
|
||||
========================================================================== */
|
||||
.bento-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.bento-card {
|
||||
background: var(--vp-c-bg-soft);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--vp-c-divider); /* Border solid yang tipis dan bersih */
|
||||
border-radius: 16px;
|
||||
padding: 40px 32px; /* Spacing dalam yang luas memberikan kesan premium */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
/* Transisi mulus untuk pergerakan, border, dan bayangan */
|
||||
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1),
|
||||
border-color 0.4s ease,
|
||||
box-shadow 0.4s ease;
|
||||
|
||||
/* Perubahan Utama: Bayangan statis yang sangat halus (elegan, tidak berisik) */
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* Transisi hover yang lebih lembut dan "mahal" */
|
||||
transition: all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
|
||||
/* Interaksi hover kartu: Terangkat sedikit dengan pendaran tipis warna brand */
|
||||
.bento-card:hover {
|
||||
/* Elevasi yang lebih tinggi saat di-hover */
|
||||
transform: translateY(-8px);
|
||||
|
||||
/* Shadow saat hover: Warna brand dengan opasitas sangat rendah agar tetap profesional */
|
||||
box-shadow: 0 20px 25px -5px rgba(101, 46, 217, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Border sedikit lebih terang */
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.bento-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-mute);
|
||||
color: var(--vp-c-brand-1);
|
||||
margin-bottom: 24px;
|
||||
transition: background 0.3s ease, color 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.bento-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Interaksi hover ikon: Mengisi warna latar dengan warna brand */
|
||||
.bento-card:hover .bento-icon {
|
||||
background: var(--vp-c-brand-1);
|
||||
color: #ffffff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.bento-card h3 {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bento-card p {
|
||||
font-size: 1em;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
9. MEDIA QUERIES (Responsive Adaptations)
|
||||
========================================================================== */
|
||||
@media (max-width: 768px) {
|
||||
.custom-divider {
|
||||
margin: 48px 0;
|
||||
}
|
||||
.bento-header {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.bento-header h2 {
|
||||
font-size: 2em;
|
||||
}
|
||||
.bento-header p {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.bento-card {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bento-grid {
|
||||
grid-template-columns: 1fr; /* Menjadi satu kolom penuh pada ponsel kecil */
|
||||
}
|
||||
.bento-card {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,7 @@ outline: [2, 3]
|
||||
|
||||
## Overview
|
||||
|
||||
The **Core App Shell** is a configuration-driven layout engine that wraps Mantine's `AppShell` component. It provides a single `<CoreAppShell>` component that renders enterprise-grade application frames — complete with headers, sidebars, aside panels, utility bars, and footers — controlled entirely through a declarative `config` object and slot-based content injection.
|
||||
The **Core App Shell** is a configuration-driven layout engine that wraps Mantine's `AppShell` component. It provides a single `CoreAppShell` component that renders enterprise-grade application frames — complete with headers, sidebars, aside panels, utility bars, and footers — controlled entirely through a declarative `config` object and slot-based content injection.
|
||||
|
||||
Key capabilities:
|
||||
|
||||
@@ -60,7 +60,7 @@ The layout engine uses a **Provider → Inner** composition pattern:
|
||||
CoreAppShell (Public API)
|
||||
└── CoreAppShellProvider (Context — state management)
|
||||
└── CoreAppShellInner (Layout rendering — consumes context)
|
||||
└── Mantine <AppShell> (CSS Grid engine)
|
||||
└── Mantine AppShell (CSS Grid engine)
|
||||
├── AppShell.Header ← slots.utilityBar + slots.header
|
||||
├── AppShell.Navbar ← slots.sidebar | slots.sidebarRail + slots.sidebarPanel
|
||||
├── AppShell.Main ← children
|
||||
@@ -120,7 +120,7 @@ type LayoutVariant = 'header-first' | 'sidebar-first' | 'top-nav';
|
||||
| `top-nav` | `default` | Header-only layout with **no visible desktop sidebar**. The sidebar is hidden on desktop but remains accessible as a mobile drawer on small screens. Ideal for documentation sites or marketing pages. |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> When `variant` is set to `top-nav`, the desktop navbar is visually hidden via `collapsed.desktop: true` and width `0`. However, the `<AppShell.Navbar>` DOM element remains mounted with responsive width props so the mobile drawer continues to function. This is an intentional design choice to avoid conditional DOM removal.
|
||||
> When `variant` is set to `top-nav`, the desktop navbar is visually hidden via `collapsed.desktop: true` and width `0`. However, the `AppShell.Navbar` DOM element remains mounted with responsive width props so the mobile drawer continues to function. This is an intentional design choice to avoid conditional DOM removal.
|
||||
|
||||
---
|
||||
|
||||
@@ -201,7 +201,7 @@ interface CoreAppShellSlots {
|
||||
| Slot | Location | Notes |
|
||||
| --------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `utilityBar` | Above the header, hidden on mobile (`display: none` below `sm`) | Typically used for environment banners, announcements, or top-level links. |
|
||||
| `header` | Main application header | Must contain its own `<Burger>` for mobile toggle (use `useCoreAppShell()` context). |
|
||||
| `header` | Main application header | Must contain its own `Burger` for mobile toggle (use `useCoreAppShell()` context). |
|
||||
| `sidebar` | Desktop navbar body (single-sidebar mode) | Ignored when `withDoubleSidebar` is `true` — use `sidebarRail` + `sidebarPanel` instead. |
|
||||
| `sidebarMobile` | Mobile drawer content | Falls back to `sidebar` if not provided. Use this to render a simplified mobile-specific navigation. |
|
||||
| `sidebarRail` | Narrow icon rail (double-sidebar mode) | Only rendered when `withDoubleSidebar` is `true`. Separated from `sidebarPanel` by a 1px border. |
|
||||
@@ -233,7 +233,7 @@ import { useCoreAppShell } from '@repo/ui/components';
|
||||
| `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed |
|
||||
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
|
||||
|
||||
> [!WARNING] > `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
|
||||
> [!WARNING] > `useCoreAppShell()` **must** be called from within a `CoreAppShell` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
|
||||
|
||||
---
|
||||
|
||||
@@ -416,8 +416,9 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When `withDoubleSidebar` is `true`, the `sidebar` slot is ignored on desktop. The navbar renders `sidebarRail` (fixed-width icon column) and `sidebarPanel` (collapsible contextual panel) side-by-side. On mobile, `sidebarMobile` takes priority, falling back to `sidebar` if not provided.
|
||||
::: info
|
||||
When `withDoubleSidebar` is `true`, the `sidebar` slot is ignored on desktop. The navbar renders `sidebarRail` (fixed-width icon column) and `sidebarPanel` (collapsible contextual panel) side-by-side. On mobile, `sidebarMobile` takes priority, falling back to `sidebar` if not provided.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
@@ -458,7 +459,7 @@ function ShellDemo() {
|
||||
|
||||
## CorePageContainer
|
||||
|
||||
A companion component for structuring page-level content within the `<AppShell.Main>` area. It provides a sticky page header and a contained, padded content region.
|
||||
A companion component for structuring page-level content within the `AppShell.Main` area. It provides a sticky page header and a contained, padded content region.
|
||||
|
||||
```tsx
|
||||
import { CorePageContainer } from '@repo/ui/components';
|
||||
@@ -508,7 +509,7 @@ interface CorePageContainerProps extends ContainerProps {
|
||||
|
||||
### Mobile Navbar Lifecycle
|
||||
|
||||
The `<AppShell.Navbar>` DOM element is **always mounted**, even when the layout variant is `top-nav`. The desktop content is hidden via `visibleFrom="sm"` and mobile content via `hiddenFrom="sm"`. This ensures Mantine's native drawer engine works correctly on mobile without conditional DOM removal breaking the transition animations.
|
||||
The `AppShell.Navbar` DOM element is **always mounted**, even when the layout variant is `top-nav`. The desktop content is hidden via `visibleFrom="sm"` and mobile content via `hiddenFrom="sm"`. This ensures Mantine's native drawer engine works correctly on mobile without conditional DOM removal breaking the transition animations.
|
||||
|
||||
### Footer Positioning in `header-first` Mode
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# What is TrackGo
|
||||
|
||||
TrackGo is a sales and logistics platform. Office staff run the **web** app. Sales, drivers, and crew work from the **mobile** app.
|
||||
|
||||
You set up the company, people, products, and customers first. Then you plan the day's visits, attach invoices or packing slips, and follow field activity.
|
||||
|
||||
## Who uses it
|
||||
|
||||
| Role | Where they work | Typical tasks |
|
||||
| --- | --- | --- |
|
||||
| Operator / admin | Web | Company data, users, privileges, plans, documents |
|
||||
| Sales | Web and mobile | Requests, orders, invoices, payments, customer visits |
|
||||
| Driver / crew | Web (plans) and mobile | Delivery plans, packing slips, customer stops |
|
||||
|
||||
What you can open depends on the **privilege** assigned to your user. If a menu is missing, ask an administrator to grant **View** for that area.
|
||||
|
||||
## The usual flow
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
actor Operator
|
||||
actor Field as "Sales or driver"
|
||||
|
||||
Operator -> Web: Set up company, users, and master data
|
||||
Operator -> Web: Create cycles or a one-off plan
|
||||
Operator -> Web: Attach invoices or packing slips
|
||||
Field -> Mobile: Clock in at the plan start branch
|
||||
Field -> Mobile: Visit customers on the plan
|
||||
Field -> Mobile: Collect payment or follow packing tasks
|
||||
Operator -> Web: Review documents and Timeline
|
||||
@enduml
|
||||
```
|
||||
|
||||
1. Add company settings, branches, divisions, customers, products, and employees.
|
||||
2. Create users and privileges so each person only sees what they need.
|
||||
3. Build **weekly cycles** (recurring) or a **one-off plan** for a date. Attach invoices (sales) or packing slips (logistics).
|
||||
4. Field staff check in at the branch on mobile, then work the plan.
|
||||
5. Sales collect payment on mobile or web. Drivers complete packing slips on the web.
|
||||
|
||||
## Sales documents
|
||||
|
||||
The sales path is:
|
||||
|
||||
**Sales request (optional) → Sales order → Invoice → Payment**
|
||||
|
||||
Processing an order can also create a packing slip for delivery. Details: [Sales workflow](/user/web/sales-workflow).
|
||||
|
||||
## Web and mobile
|
||||
|
||||
- [Web](/user/web/) — setup, plans, sales, logistics, timeline, CSV import
|
||||
- [Mobile](/user/mobile/) — login, branch check-in, today's plan, customers, payments
|
||||
|
||||
## Coming soon
|
||||
|
||||
These items are not in the product yet:
|
||||
|
||||
- Web **Dashboard** (the menu exists; the page is a placeholder)
|
||||
- **Live** position on the web map (Timeline plays back a recorded day instead)
|
||||
- Mobile **packing slip status** updates (logistics tasks are listed, not updated on the phone)
|
||||
- Forgot password, remember me, and sign-up on login screens
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { MapPin } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Check in
|
||||
|
||||
<p class="doc-lead"><MapPin :size="20" class="doc-icon" /> Clock in at the plan start branch before field work</p>
|
||||
|
||||
|
||||
Sales and drivers **clock in at the plan start branch** before they start field work. The app does not block browsing Home, Plan, or Customers without check-in, but create-customer, customer visit check-in, sales documents, and payments stay disabled until you are on duty.
|
||||
|
||||
## Before you clock in
|
||||
|
||||
You need **today's plan** with a start branch. If there is no plan, Clock In is unavailable and the screen explains there is no plan for today. An operator must [create or generate the plan](/user/web/plans) on the web.
|
||||
|
||||
## Clock in and out
|
||||
|
||||
1. From Home, tap **Check in now** if you see **You have not checked in**, or open the check-in screen.
|
||||
2. Confirm your location on the map (OpenStreetMap). Use recenter if needed.
|
||||
3. Tap **Clock In Now**. The app sends GPS coordinates for the plan **start branch**.
|
||||
4. While on duty, the app records location footprints in the background (the interval comes from company GPS settings).
|
||||
5. Tap **Clock Out** when the shift ends. Tracking stops.
|
||||
|
||||
The screen shows your name, date, coordinates, and clock-in / clock-out times.
|
||||
|
||||
A circle on the map is a visual guide. The Clock In button is enabled when a plan exists and you are not already checked in — it is not blocked by standing outside that circle.
|
||||
|
||||
## After check-in
|
||||
|
||||
While branch attendance is open:
|
||||
|
||||
- You can create a customer
|
||||
- You can check in to a customer on a visit
|
||||
- Sales actions (request, order, payment) also need a **customer visit** check-in for that customer
|
||||
|
||||
If GPS moves too far from a customer visit, Home can show a checkout warning so you can check out of the visit.
|
||||
|
||||
## Coming soon
|
||||
|
||||
- Check-in by **NFC** or **QR** (the screen always uses GPS)
|
||||
- Enforcing the check-in radius before Clock In is enabled
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Daily work
|
||||
|
||||
<p class="doc-lead"><Calendar :size="20" class="doc-icon" /> Home, Plan, and Customers after you are on duty</p>
|
||||
|
||||
|
||||
After [branch check-in](/user/mobile/check-in), use Home, Plan, and Customers to work the day.
|
||||
|
||||
## Home
|
||||
|
||||
Home has two tabs: **Plan** and **Timeline**.
|
||||
|
||||
**Plan tab**
|
||||
|
||||
- Greeting and your position (Sales, Driver, or Crew)
|
||||
- Banner if you have not checked in at the branch
|
||||
- Today's destinations — **Open** starts a customer visit
|
||||
- Active visit card with checkout
|
||||
- **Today Task** — invoices (sales) or packing-slip codes (driver/crew)
|
||||
|
||||
**Timeline tab**
|
||||
|
||||
Today's field events grouped by customer (check-in/out, sales documents, new customers). Pull down to refresh.
|
||||
|
||||
Logout is on the avatar menu.
|
||||
|
||||
### Coming soon on Home
|
||||
|
||||
- Work **performance** totals (customer / invoice / payment / packing counts are not shown on the screen)
|
||||
- Messages and Notifications icons do nothing yet
|
||||
|
||||
## Plan
|
||||
|
||||
The **Plan** tab lists today's destinations. Open a stop to go to the customer visit screen.
|
||||
|
||||
If a plan exists, use **Add customer to plan**:
|
||||
|
||||
1. Search customers who are not already on the plan.
|
||||
2. For **sales** plans, you can pick outstanding invoices to attach.
|
||||
3. Confirm **Add to plan**.
|
||||
|
||||
You cannot create a new daily plan on the phone — only extend an existing one.
|
||||
|
||||
### Coming soon on Plan
|
||||
|
||||
- Picking **packing slips** when adding a customer to a logistics plan
|
||||
|
||||
## Customers
|
||||
|
||||
Search and sort the list by **Name**, **Code**, or **Distance**. Tap a row for the profile.
|
||||
|
||||
**Create** (FAB) is available only after branch check-in. The form includes code, name, phone, NFC ID (typed), address, map location, and optional contacts.
|
||||
|
||||
### Visit a customer
|
||||
|
||||
Open a destination from the plan, or open the visit route from the profile.
|
||||
|
||||
1. Complete **Customer check-in (GPS)** when sales actions are locked.
|
||||
2. Then you can use **Sales actions**.
|
||||
|
||||
Without branch check-in you see **Branch check-in is required**. Without a visit check-in for that customer you see that you must check in to the customer.
|
||||
|
||||
### Sales request and sales order
|
||||
|
||||
**Create sales request** and **Create sales order (no packing)** send a document with the **first loaded product** and quantity **1**. There is no product picker or cart on the phone.
|
||||
|
||||
For full line items, create the documents on the [web](/user/web/sales-workflow).
|
||||
|
||||
### Payments
|
||||
|
||||
Outstanding invoices on the profile/visit list have **Create payment**.
|
||||
|
||||
1. Confirm invoice code, status, and balance.
|
||||
2. Enter **amount** (starts at the remaining balance) and optional **notes**.
|
||||
3. Record the payment. GPS is attached when available.
|
||||
|
||||
Amount must be greater than zero and not more than the balance. The same branch + customer visit rules apply.
|
||||
|
||||
## What drivers can do today
|
||||
|
||||
Drivers and crew follow the logistics plan and see packing-slip codes on Home. **Changing packing slip status on mobile is Coming soon** — complete slips on the [web](/user/web/logistics).
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { Smartphone } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Mobile overview
|
||||
|
||||
<p class="doc-lead"><Smartphone :size="20" class="doc-icon" /> Field app for sales, drivers, and crew</p>
|
||||
|
||||
|
||||
The TrackGo mobile app is for **sales**, **drivers**, and **crew** in the field. Bottom tabs are **Home**, **Plan**, and **Customers**. Branch check-in is a separate screen.
|
||||
|
||||
Your employee position decides which plan you get:
|
||||
|
||||
| Position | Plan type | Today's tasks on Home |
|
||||
| --- | --- | --- |
|
||||
| Sales | Sales | Invoices on today's plan |
|
||||
| Driver or crew | Logistics | Packing slip codes on today's plan |
|
||||
|
||||
If the account is not linked to an employee, Home and Plan explain that the profile is not linked.
|
||||
|
||||
## Sign in
|
||||
|
||||
1. Open the app.
|
||||
2. Enter **Employee ID** and **Password**.
|
||||
3. Tap **Sign In**.
|
||||
|
||||
Username rules: 3–32 characters. Password: 8–72 characters.
|
||||
|
||||
## A typical day
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
start
|
||||
:Sign in;
|
||||
:Open Home or Check in;
|
||||
:Clock in at the plan start branch;
|
||||
:Open today's plan;
|
||||
:Check in to a customer;
|
||||
:Collect payment or create a sales document;
|
||||
:Clock out of the visit and the branch;
|
||||
stop
|
||||
@enduml
|
||||
```
|
||||
|
||||
1. [Check in](/user/mobile/check-in) at the branch (required before most field actions).
|
||||
2. [Daily work](/user/mobile/daily-work) — plan, customers, visits, payments, Home timeline.
|
||||
|
||||
## Coming soon on login
|
||||
|
||||
These controls are visible but do not work yet:
|
||||
|
||||
- Remember Me
|
||||
- Forgot Password
|
||||
- Sign in with email or phone
|
||||
- Sign up
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { FileText } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Import CSV
|
||||
|
||||
<p class="doc-lead"><FileText :size="20" class="doc-icon" /> Import on document lists when your privilege includes Import</p>
|
||||
|
||||
You can bulk-load documents from a **.csv** file on these index pages:
|
||||
|
||||
| Document | Menu |
|
||||
| --- | --- |
|
||||
| Sales requests | Sales → Activities → Sales Requests |
|
||||
| Sales orders | Sales → Activities → Sales Orders |
|
||||
| Sales invoices | Sales → Activities → Sales Invoices |
|
||||
| Sales payments | Sales → Activities → Sales Payments |
|
||||
| Packing slips | Logistics → Activities → Packing slips |
|
||||
|
||||
## How to import
|
||||
|
||||
1. Open the list.
|
||||
2. Click **Import CSV** (hidden if you do not have **Import** on that area).
|
||||
3. Choose a `.csv` file and confirm.
|
||||
|
||||
There is no separate import wizard beyond the file upload.
|
||||
|
||||
## What you cannot import here
|
||||
|
||||
Master data (branches, customers, products), users, privileges, cycles, and plans do **not** have an Import CSV button. Create those records in their own screens, or generate plans from cycles.
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { LayoutDashboard, MapPin, ShoppingCart, Truck, Settings } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Web overview
|
||||
|
||||
<p class="doc-lead"><LayoutDashboard :size="20" class="doc-icon" /> Browser and desktop app for operators, sales, and logistics.</p>
|
||||
|
||||
Sign in with **Username** and **Password** on the login page. You can switch the language between English and Indonesian.
|
||||
|
||||
After login, the sidebar shows only the menus your privilege allows.
|
||||
|
||||
## Menus
|
||||
|
||||
| Menu | Icon | What you do there |
|
||||
| --- | --- | --- |
|
||||
| Dashboard | <LayoutDashboard :size="16" class="doc-icon" /> | Coming soon |
|
||||
| Timeline | <MapPin :size="16" class="doc-icon" /> | Play back a day's GPS footprints and field activities for sales employees |
|
||||
| Sales | <ShoppingCart :size="16" class="doc-icon" /> | Employees, cycles, requests, orders, invoices, payments, plans, reports |
|
||||
| Logistics | <Truck :size="16" class="doc-icon" /> | Drivers and crew, cycles, packing slips, plans, reports |
|
||||
| Settings | <Settings :size="16" class="doc-icon" /> | Branches, divisions, customers, products, company settings, users, privileges |
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. [Set up your company](/user/web/setup) — master data, users, and privileges
|
||||
2. [Plans and visits](/user/web/plans) — one-off plans and weekly cycles
|
||||
3. [Sales workflow](/user/web/sales-workflow) — request, order, invoice, payment
|
||||
4. [Logistics](/user/web/logistics) — packing slips and delivery plans
|
||||
5. [Timeline](/user/web/timeline) — activity history on the map
|
||||
6. [Import CSV](/user/web/import) — bulk load documents
|
||||
|
||||
## Reports
|
||||
|
||||
**Sales → Sales Reports** and **Logistics → Logistics Reports** show report tables from the server. If none are configured, the page says there are no reports available.
|
||||
|
||||
## Status and permissions
|
||||
|
||||
Most master-data records use **Draft**, **Active**, and **Archived**. Sales and logistics documents use their own statuses (see each guide).
|
||||
|
||||
Buttons such as Create, Process, Submit, and Approve appear only when your privilege includes that action (**Create**, **Update**, **Delete**, or **Import**).
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { Truck, Package, Calendar } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Logistics
|
||||
|
||||
<p class="doc-lead"><Truck :size="20" class="doc-icon" /> Logistics menus for drivers, crew, packing slips, and delivery plans</p>
|
||||
|
||||
Use Logistics when you need delivery paperwork and driver routes. Sales still owns requests, orders, invoices, and payments.
|
||||
|
||||
## Employees and cycles
|
||||
|
||||
- **Logistics → Data → Employees** — positions **driver** and **crew**
|
||||
- **Logistics → Data → Logistics Cycles** — weekly visit templates for those employees
|
||||
|
||||
Generate daily plans from cycles the same way as sales. See [Plans and visits](/user/web/plans).
|
||||
|
||||
## Packing slips
|
||||
|
||||
<p class="doc-lead"><Package :size="18" class="doc-icon" /> Logistics → Activities → Packing slips</p>
|
||||
|
||||
Packing slips are created when you **Process** a sales order with **Generate packing slip** checked. You can also create one by hand and link it to a sales order.
|
||||
|
||||
Typical fields: code, date, customer, sales order, location, products, notes.
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
start
|
||||
if (Status is Draft?) then (yes)
|
||||
:Change status to Processed\nor Cancel;
|
||||
endif
|
||||
if (Status is Processed?) then (yes)
|
||||
:Complete;
|
||||
:Enter delivered quantity per product;
|
||||
if (Remaining quantity?) then (yes)
|
||||
:A new packing slip is created\nfor the remainder;
|
||||
else (no)
|
||||
:Slip is Completed;
|
||||
endif
|
||||
endif
|
||||
stop
|
||||
@enduml
|
||||
```
|
||||
|
||||
1. If the status is **Draft**, use **Change status** to move it to **Processed**, or **Cancel** it. There is no **Process** button here.
|
||||
2. When it is **Processed**, click **Complete**. Enter the **delivered quantity** for each product. Remaining quantity opens a **new packing slip**.
|
||||
3. You can **Cancel** a slip while it is **Draft** or **Processed**.
|
||||
|
||||
When every related packing slip is completed, the linked sales order can move to **Completed**. If you skip packing on the order, the order does not wait for a slip.
|
||||
|
||||
### Status actions
|
||||
|
||||
| Current status | Buttons | Change status |
|
||||
| --- | --- | --- |
|
||||
| Draft | Cancel | Processed, Cancelled |
|
||||
| Processed | Complete, Cancel | Completed, Cancelled |
|
||||
| Completed | — | — |
|
||||
| Cancelled | — | — |
|
||||
|
||||
**Complete** always asks for delivered quantities.
|
||||
|
||||
You can [import packing slips from CSV](/user/web/import) if your privilege includes **Import**.
|
||||
|
||||
## Logistics plans
|
||||
|
||||
<p class="doc-lead"><Calendar :size="18" class="doc-icon" /> Logistics → Activities → Logistics Plans</p>
|
||||
|
||||
Same plan model as sales: employee, date, start/end branch, customers. Attach **packing slips** instead of invoices.
|
||||
|
||||
Drivers and crew open today's plan on [mobile](/user/mobile/daily-work). Home shows packing-slip codes as today's tasks.
|
||||
|
||||
## Coming soon
|
||||
|
||||
**Updating packing slip status on mobile** is not available. Drivers see assigned packing codes on Home; they complete slips in the web app (or an operator does).
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { Calendar, Repeat } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Plans and visits
|
||||
|
||||
<p class="doc-lead"><Calendar :size="20" class="doc-icon" /> Sales → Activities → Sales Plans, or Logistics → Activities → Logistics Plans</p>
|
||||
|
||||
A **plan** is one employee's work for **one date**: start branch, end branch, customers to visit, and documents to collect.
|
||||
|
||||
There is no recurrence switch on the plan form. Recurring work uses **cycles**, then **Generate**.
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
start
|
||||
if (Need a repeating weekly route?) then (yes)
|
||||
:Create a cycle with weekday routes;
|
||||
:Generate plans for a date range;
|
||||
else (no)
|
||||
:Create a one-off plan for a date;
|
||||
endif
|
||||
:Attach invoices (sales) or packing slips (logistics);
|
||||
:Field staff open the plan on mobile;
|
||||
stop
|
||||
@enduml
|
||||
```
|
||||
|
||||
## One-off plan
|
||||
|
||||
**Create page description in the app:** "Create a one-off daily plan for an employee."
|
||||
|
||||
1. Open **Sales Plans** or **Logistics Plans**.
|
||||
2. Click **Create**.
|
||||
3. Choose the **employee**, **date** (today or later), **start branch**, and **end branch**.
|
||||
4. Add **customers** as destinations (order matters for the route).
|
||||
5. After customers are selected:
|
||||
- Sales plans: attach **invoices** for those customers
|
||||
- Logistics plans: attach **packing slips** for those customers
|
||||
6. Save. Status is **Draft**, **Active**, or **Archived** like other master records.
|
||||
|
||||
You can preview customers and attached documents on the form.
|
||||
|
||||
Saving a sales plan does **not** change invoice status.
|
||||
|
||||
## Weekly cycles (recurring)
|
||||
|
||||
<p class="doc-lead"><Repeat :size="18" class="doc-icon" /> Sales → Data → Sales Cycles, or Logistics → Data → Logistics Cycles</p>
|
||||
|
||||
A cycle is a **weekly visit template** for one employee: cycle number and, for each weekday, whether the day is active, start/end branch, and customers.
|
||||
|
||||
1. Create a cycle for the employee.
|
||||
2. Enable the weekdays they work and fill the route.
|
||||
3. Set the cycle to **Active**.
|
||||
|
||||
## Generate plans from cycles
|
||||
|
||||
On the plans list, click **Generate**.
|
||||
|
||||
1. Choose the **employee**.
|
||||
2. Choose **from** and **to** dates.
|
||||
3. Confirm. The app creates daily plans from the cycle and reports how many were **created** and **skipped** (for example if a plan already exists).
|
||||
|
||||
## Who uses the plan
|
||||
|
||||
- **Web operators** create and generate plans, then attach documents.
|
||||
- **Sales and drivers on mobile** see today's plan after they exist. They can add a customer to an existing plan; they cannot create a new plan on the phone.
|
||||
|
||||
See [Mobile daily work](/user/mobile/daily-work).
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { ShoppingCart, ClipboardList, Box, Receipt, CreditCard } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Sales workflow
|
||||
|
||||
<p class="doc-lead"><ShoppingCart :size="20" class="doc-icon" /> Sales → Activities</p>
|
||||
|
||||
How to run a sale: from a request or order, through packing and invoicing, to payment.
|
||||
|
||||
You need permission to view each menu. Status buttons (Process, Complete, Submit, Approve, and so on) appear only if you are allowed to edit.
|
||||
|
||||
## Before you start
|
||||
|
||||
Set up the records that every sales document needs:
|
||||
|
||||
| You need | Where to create it |
|
||||
| --- | --- |
|
||||
| Division, branch, customer, product | [Settings → Data](/user/web/setup) |
|
||||
| Salesperson | Sales → Data → Employees |
|
||||
|
||||
If a menu is missing, ask an administrator to give your privilege **View** (and **Update** for status buttons) on that area.
|
||||
|
||||
## The usual flow
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
start
|
||||
:Sales request (optional);
|
||||
:Sales order (Draft);
|
||||
:Process order;
|
||||
fork
|
||||
:Sales invoice created;
|
||||
fork again
|
||||
:Packing slip created\n(unless you skip it);
|
||||
end fork
|
||||
if (Packing slip exists?) then (yes)
|
||||
:Complete delivered quantities;
|
||||
endif
|
||||
:Create sales payment;
|
||||
:Submit then Approve;
|
||||
stop
|
||||
@enduml
|
||||
```
|
||||
|
||||
1. Optionally create and approve a **sales request**.
|
||||
2. Create a **sales order** (from the request, or from scratch).
|
||||
3. Open the draft order and click **Process**. Leave **Generate packing slip** checked unless you do not need delivery paperwork. Processing creates a **sales invoice**, and a **packing slip** unless you skip it.
|
||||
4. If there is a packing slip, open **Logistics → Packing slips**, then **Complete** and enter how much was delivered.
|
||||
5. Open the invoice and click **Create Sales Payment**. **Submit** the payment, then **Approve** it (or **Reject** / **Rollback**).
|
||||
|
||||
You can also [import CSV](/user/web/import) for requests, orders, invoices, and payments.
|
||||
|
||||
## Sales request
|
||||
|
||||
<p class="doc-lead"><ClipboardList :size="18" class="doc-icon" /> Sales → Activities → Sales Requests</p>
|
||||
|
||||
A request is optional. You can go straight to a sales order.
|
||||
|
||||
1. Click **Create**. Fill in date, salesperson, branch, division, customer, address, and at least one product with a quantity. Product price and document code are optional. Save.
|
||||
2. While the status is **Draft**, click **Submit** to send it for approval. You can also use **Change status** to mark it **Rejected**.
|
||||
3. While it is **Pending**, click **Approve** or **Reject**. **Change status** can send it back to **Draft**.
|
||||
4. After it is **Approved**, there is nothing more to do on the request itself.
|
||||
5. On the request detail page, click **Create Sales Order**. The order form opens with the request's customer, location, and products already filled in.
|
||||
|
||||
## Sales order
|
||||
|
||||
<p class="doc-lead"><Box :size="18" class="doc-icon" /> Sales → Activities → Sales Orders</p>
|
||||
|
||||
1. Click **Create**. Fill in the same kind of details as a request. On a new order you can pick a sales request under **Source**; that copies the request into the form. Save. A new order starts as **Draft**.
|
||||
2. On a **Draft** order, click **Process**.
|
||||
- You will see: *Processing creates a sales invoice and, unless skipped, a packing slip.*
|
||||
- **Generate packing slip** is checked by default. Uncheck it if you do not want a packing slip.
|
||||
3. On **Draft** or **Processed**, you can **Cancel**. There is no **Complete** button on orders. If the order later shows **Completed**, that was not something you clicked on this screen.
|
||||
4. On the order detail page you can see linked packing slips and invoices, and click **Create Sales Invoice**.
|
||||
|
||||
You can select several orders and Process or Cancel them together, as long as they are all in a status that allows that action.
|
||||
|
||||
## Packing slip
|
||||
|
||||
Packing slips live under **Logistics**, not Sales. See [Logistics](/user/web/logistics).
|
||||
|
||||
## Sales invoice
|
||||
|
||||
<p class="doc-lead"><Receipt :size="18" class="doc-icon" /> Sales → Activities → Sales Invoices</p>
|
||||
|
||||
Invoices appear when you **Process** an order. You can also create one yourself, optionally from an order or a packing slip.
|
||||
|
||||
On the invoice, the only status button is **Cancel** (while the invoice is **Draft**, **Processed**, or **Partial**). You cannot choose Processed, Partial, or Completed yourself. Those labels can still show on the invoice.
|
||||
|
||||
On the invoice detail page, click **Create Sales Payment**. The payment form opens with that invoice and its remaining balance.
|
||||
|
||||
You can attach invoices to a [sales plan](/user/web/plans). Saving the plan does not change invoice status.
|
||||
|
||||
## Sales payment
|
||||
|
||||
<p class="doc-lead"><CreditCard :size="18" class="doc-icon" /> Sales → Activities → Sales Payments</p>
|
||||
|
||||
1. Click **Create**. Enter the date and at least one line: an invoice and an amount. Images and notes are optional. Save. A new payment starts as **Draft**.
|
||||
2. Click **Submit** to move it to **Pending**. You can also use **Change status** to mark it **Rejected**.
|
||||
3. While it is **Pending**, click **Approve**, **Reject**, or **Rollback** (back to **Draft**).
|
||||
4. After **Approved**, you cannot change the status further. A **Rejected** payment can be sent back to **Draft** with **Change status**.
|
||||
|
||||
Approving a payment does not change the invoice status from this screen. Refresh the invoice if you need to see an updated balance.
|
||||
|
||||
Field staff can also [record a payment on mobile](/user/mobile/daily-work) from an outstanding invoice.
|
||||
|
||||
## What you can do at each status
|
||||
|
||||
**Buttons** are the labelled actions on the list or detail page. **Change status** is a separate dialog that lists the statuses you may pick. Invoices do not have Change status.
|
||||
|
||||
### Sales request and sales payment
|
||||
|
||||
Both use Draft → Pending → Approved or Rejected.
|
||||
|
||||
| Current status | Buttons | Change status |
|
||||
| --- | --- | --- |
|
||||
| Draft | Submit | Rejected |
|
||||
| Pending | Approve, Reject. Payments also have Rollback (back to Draft) | Draft, Approved, Rejected |
|
||||
| Approved | — | — |
|
||||
| Rejected | — | Draft |
|
||||
|
||||
### Sales order
|
||||
|
||||
| Current status | Buttons | Change status |
|
||||
| --- | --- | --- |
|
||||
| Draft | Process, Cancel | Processed, Cancelled |
|
||||
| Processed | Cancel | Cancelled |
|
||||
| Completed | — | — |
|
||||
| Cancelled | — | — |
|
||||
|
||||
**Process** always opens the packing-slip checkbox dialog (not Change status).
|
||||
|
||||
### Sales invoice
|
||||
|
||||
You may see Draft, Processed, Partial, Completed, or Cancelled.
|
||||
|
||||
| Current status | What you can do |
|
||||
| --- | --- |
|
||||
| Draft, Processed, or Partial | Cancel |
|
||||
| Completed | No status buttons |
|
||||
| Cancelled | No status buttons |
|
||||
|
||||
## What you should not expect to click
|
||||
|
||||
These are not buttons on the screens:
|
||||
|
||||
- **Complete** on a sales order
|
||||
- Setting an invoice to Processed, Partial, or Completed yourself
|
||||
- Changing an invoice's status by putting it on a sales plan
|
||||
- Changing an invoice's status by approving a payment
|
||||
- Cancelling an order, packing slip, or invoice in one click for all related documents — **Cancel** applies only to the document you are looking at
|
||||
|
||||
If a related document's status changes after you act, open that document again to see the latest value.
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { Settings, Users, Shield } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Set up your company
|
||||
|
||||
<p class="doc-lead"><Settings :size="20" class="doc-icon" /> Settings → Data and Settings → User</p>
|
||||
|
||||
Do this before you create plans or sales documents. Records that other screens need (branch, division, customer, product, employee) must exist first.
|
||||
|
||||
## Company settings
|
||||
|
||||
**Menu:** Settings → Data → Company settings
|
||||
|
||||
This is a single form, not a list. Save:
|
||||
|
||||
| Field | Used for |
|
||||
| --- | --- |
|
||||
| Cycle start date | When weekly visit cycles start counting |
|
||||
| Check-in radius (meters) | How close field staff should be when checking in |
|
||||
| GPS interval (seconds) | How often the mobile app records a footprint while on duty |
|
||||
| Checkout warning radius (meters) | When mobile warns that the user left a customer visit |
|
||||
|
||||
## Divisions, branches, customers, products
|
||||
|
||||
**Menu:** Settings → Data
|
||||
|
||||
Each of these is a list with Create, Detail, Edit, and Duplicate. Status is **Draft**, **Active**, or **Archived**.
|
||||
|
||||
| Record | Typical fields |
|
||||
| --- | --- |
|
||||
| Divisions | Name, code |
|
||||
| Branches | Code, name, phone, NFC ID, address, map location, division, working days and hours |
|
||||
| Customers | Code, name, phone, address, map location, NFC ID; contacts on create |
|
||||
| Products | Code, name, unit, price, brand |
|
||||
|
||||
Pick a location on the map for branches and customers (or type latitude and longitude).
|
||||
|
||||
## Employees
|
||||
|
||||
Employees are split by purpose:
|
||||
|
||||
| Menu | Positions |
|
||||
| --- | --- |
|
||||
| Sales → Data → Employees | Sales |
|
||||
| Logistics → Data → Employees | Driver, crew |
|
||||
|
||||
Fields: code, name, phone, position. Link a **user** to an employee later so mobile knows whether to load a sales or logistics plan.
|
||||
|
||||
## Privileges
|
||||
|
||||
<p class="doc-lead"><Shield :size="18" class="doc-icon" /> Settings → User → Privileges</p>
|
||||
|
||||
A privilege is a named access set. On the form, set **name**, **code**, and a permission matrix. For each area you can allow:
|
||||
|
||||
- **View** — show the menu and open the pages
|
||||
- **Create** — create and duplicate
|
||||
- **Update** — edit and change status
|
||||
- **Delete** — delete
|
||||
- **Import** — CSV import on supported lists
|
||||
|
||||
Without **View**, the sidebar hides that item.
|
||||
|
||||
## Users
|
||||
|
||||
<p class="doc-lead"><Users :size="18" class="doc-icon" /> Settings → User → Users</p>
|
||||
|
||||
1. Click **Create**.
|
||||
2. Enter **username** and **password** (password is required on create; on edit you can leave it blank to keep the current one).
|
||||
3. Choose a **privilege**.
|
||||
4. Optionally link an **employee** so field login maps to sales or logistics.
|
||||
|
||||
Activate the user when they should be able to sign in.
|
||||
|
||||
## Coming soon
|
||||
|
||||
- **Remember me** on the web login form is not saved
|
||||
- **Forgot password** is not available
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { MapPin } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
# Timeline
|
||||
|
||||
<p class="doc-lead"><MapPin :size="20" class="doc-icon" /> Timeline in the sidebar</p>
|
||||
|
||||
Timeline shows **one day's** recorded GPS footprints and field activities for **sales** employees. It is a playback of what the mobile app stored while the person was on duty, not a live feed.
|
||||
|
||||
## How to use it
|
||||
|
||||
1. Open **Timeline**.
|
||||
2. Pick a **date**.
|
||||
3. Optionally filter by **employee** (sales employees), or leave all employees.
|
||||
4. Search activities if the list is long.
|
||||
5. Switch tabs **On the way** and **Completed**.
|
||||
6. Use **Playback** (play, pause, time) to move along the recorded path. The overlay **Current location** is the playback cursor, not a live GPS point.
|
||||
|
||||
If there is no data for that day, the map shows that there is no timeline data.
|
||||
|
||||
## What activities look like
|
||||
|
||||
The list can include:
|
||||
|
||||
- Branch check-in / check-out
|
||||
- Customer check-in / check-out
|
||||
- Sales order created
|
||||
- Sales request created
|
||||
- Sales payment created
|
||||
- Customer created
|
||||
|
||||
Footprints are recorded on mobile after branch clock-in, using the **GPS interval** in [company settings](/user/web/setup).
|
||||
|
||||
## Coming soon
|
||||
|
||||
- **Live** tracking of sales or drivers on the web map
|
||||
- A dedicated **logistics / driver** timeline (this screen loads sales employees)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
@@ -7,7 +7,7 @@ test.describe('login', () => {
|
||||
await mockBackend(page);
|
||||
await page.goto('/auth/login');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Login to your account!' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Sign in to TrackGo' })).toBeVisible();
|
||||
await expect(page.getByLabel('Username')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();
|
||||
@@ -21,8 +21,8 @@ test.describe('login', () => {
|
||||
await page.getByLabel('Password').fill(E2E_LOGIN.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/app(\/|$)/);
|
||||
await expect(page.getByRole('heading', { name: 'Full Page' })).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/app\/timeline\/index/);
|
||||
await expect(page.getByPlaceholder('Search activities')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows an error and stays on login when credentials are rejected', async ({ page }) => {
|
||||
@@ -35,6 +35,6 @@ test.describe('login', () => {
|
||||
|
||||
await expect(page.getByText('Invalid username or password')).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/auth\/login/);
|
||||
await expect(page.getByRole('heading', { name: 'Login to your account!' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Sign in to TrackGo' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { WEB_URL } from '../../core/constants/web-url';
|
||||
import { AuthPageGuard } from '../../core/lib/auth-guard';
|
||||
|
||||
const LoginPage = lazy(() => import('./login'));
|
||||
@@ -9,7 +10,7 @@ export default function AuthModule() {
|
||||
<AuthPageGuard>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<Navigate to="/auth/login" replace={true} />} />
|
||||
<Route path="/" element={<Navigate to={WEB_URL.LOGIN} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</AuthPageGuard>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
@@ -1,6 +0,0 @@
|
||||
export const AndroidIcon = (props: any) => (
|
||||
<svg viewBox="0 0 24 24" fill="#3DDC84" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M17.5 12C18.33 12 19 11.33 19 10.5C19 9.67 18.33 9 17.5 9C16.67 9 16 9.67 16 10.5C16 11.33 16.67 12 17.5 12ZM6.5 12C7.33 12 8 11.33 8 10.5C8 9.67 7.33 9 6.5 9C5.67 9 5 9.67 5 10.5C5 11.33 5.67 12 6.5 12Z" />
|
||||
<path d="M19.46 8.52C19.31 8.52 19.17 8.56 19.04 8.65L17.12 9.99C15.56 9.29 13.83 8.9 12 8.9C10.17 8.9 8.44 9.29 6.88 9.99L4.96 8.65C4.83 8.56 4.69 8.52 4.54 8.52C4.1 8.52 3.74 8.88 3.74 9.32C3.74 9.58 3.87 9.82 4.08 9.97L5.7 11.11C3.47 12.8 2 15.39 2 18.29H22C22 15.39 20.53 12.8 18.3 11.11L19.92 9.97C20.13 9.82 20.26 9.58 20.26 9.32C20.26 8.88 19.9 8.52 19.46 8.52Z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
export const ChromeIcon = (props: any) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 7C9.23858 7 7 9.23858 7 12C7 14.7614 9.23858 17 12 17C14.7614 17 17 14.7614 17 12C17 9.23858 14.7614 7 12 7Z"
|
||||
fill="#FACC15"
|
||||
/>
|
||||
<path d="M12 2C16.8 2 20.8 5.4 21.8 10H12L7 18.6C5.1 16.9 4 14.6 4 12C4 7.6 7.6 4 12 4V2Z" fill="#F87171" />
|
||||
<path
|
||||
d="M22 12C22 17.5 17.5 22 12 22C10 22 8.1 21.4 6.5 20.3L11.5 11.6L21.8 10C21.9 10.6 22 11.3 22 12Z"
|
||||
fill="#4ADE80"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="4" fill="#3B82F6" />
|
||||
</svg>
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
export const GoogleIcon = (props: any) => (
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -1,12 +0,0 @@
|
||||
export const IconCircle = ({ x, y, children }: { x: number; y: number; children: React.ReactNode }) => (
|
||||
<div
|
||||
className="absolute flex items-center justify-center w-10 h-10 bg-white rounded-full shadow-lg"
|
||||
style={{
|
||||
left: `calc(50% + ${x}px)`,
|
||||
top: `calc(50% + ${y}px)`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
export const MicrosoftIcon = (props: any) => (
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M1 1h10.5v10.5H1z" fill="#f35325" />
|
||||
<path d="M12.5 1H23v10.5H12.5z" fill="#81bc06" />
|
||||
<path d="M1 12.5h10.5V23H1z" fill="#05a6f0" />
|
||||
<path d="M12.5 12.5H23V23H12.5z" fill="#ffba08" />
|
||||
</svg>
|
||||
);
|
||||
@@ -1,120 +1,14 @@
|
||||
import { Apple, Globe, Mail, MessageCircle } from 'lucide-react';
|
||||
import { IconCircle } from './icon-circle';
|
||||
import { Ring } from './ring';
|
||||
import { AndroidIcon } from './android-icon';
|
||||
import { ChromeIcon } from './chrome-icon';
|
||||
import { Image } from '@repo/ui/components';
|
||||
import loginBanner from '../assets/login-banner.webp';
|
||||
|
||||
export function RightSection() {
|
||||
return (
|
||||
// <div className="relative flex flex-col items-center justify-center w-full mt-6 mr-6 mb-6 p-12 overflow-hidden shadow-inner bg-linear-to-b from-brand-50 via-brand-300 to-brand-50 border-l border-brand-100 rounded-2xl">
|
||||
<div className="relative flex flex-col items-center justify-center w-full p-12 overflow-hidden shadow-inner bg-linear-to-b from-brand-50 via-brand-300 to-brand-50 border-brand-100 ">
|
||||
{/* 1. Glossy & Glassmorphic Orbs */}
|
||||
<div className="absolute -top-10 -right-10 w-72 h-72 bg-white/40 rounded-full blur-3xl pointer-events-none mix-blend-overlay" />
|
||||
<div className="absolute top-1/3 left-1/4 w-96 h-96 bg-brand-400/20 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-10 -left-10 w-80 h-80 bg-white/60 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<h2 className="relative z-10 mt-6 mb-3 text-3xl xl:text-4xl font-bold tracking-tight text-center text-brand-950 leading-tight">
|
||||
Write Better {/* Wrapper luar KHUSUS untuk Drop Shadow */}
|
||||
<span className="block sm:inline filter drop-shadow-[0_2px_8px_rgba(var(--color-brand-500),0.15)]">
|
||||
{/* Elemen dalam KHUSUS untuk Gradien Teks */}
|
||||
<span className="text-transparent bg-clip-text bg-linear-to-r from-brand-700 via-brand-600 to-brand-700">
|
||||
Everywhere
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{/* Graphics Area */}
|
||||
<div className="flex-1 w-full relative flex items-center justify-center min-h-100">
|
||||
{/* Concentric Rings */}
|
||||
<Ring size={200} />
|
||||
<Ring size={300} />
|
||||
<Ring size={420} />
|
||||
<Ring size={540} />
|
||||
|
||||
{/* Center Logo */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
|
||||
<div className="w-18 h-18 bg-brand-600 rounded-full flex items-center justify-center shadow-xl shadow-brand-600/40 relative">
|
||||
{/* Glowing backdrop */}
|
||||
<div className="absolute inset-0 bg-brand-600 rounded-full blur-md opacity-40"></div>
|
||||
{/* Custom P/Pen Logo from reference */}
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
className="w-10.5 h-10.5 text-white relative z-10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 7C12 6.44772 12.4477 6 13 6H18C21.3137 6 24 8.68629 24 12C24 15.3137 21.3137 18 18 18H15V25C15 25.5523 14.5523 26 14 26H13C12.4477 26 12 25.5523 12 25V7ZM15 9V15H18C19.6569 15 21 13.6569 21 12C21 10.3431 19.6569 9 18 9H15Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M16.5 10L16.5 14L15 14L15 10L16.5 10Z" className="fill-brand-600" />
|
||||
<path d="M15.75 16L14 20L17.5 20L15.75 16Z" className="fill-brand-600" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orbiting Icons */}
|
||||
|
||||
{/* Ring 1 (r=100) */}
|
||||
<div className="animate-floating" style={{ animationDelay: '0s' }}>
|
||||
<IconCircle x={-70} y={-70}>
|
||||
<Mail className="w-5 h-5 text-[#0078D4]" fill="#0078D4" stroke="#fff" />
|
||||
</IconCircle>
|
||||
</div>
|
||||
|
||||
<div className="animate-floating" style={{ animationDelay: '0.5s' }}>
|
||||
<IconCircle x={85} y={35}>
|
||||
<MessageCircle className="w-5.5 h-5.5 text-[#0084FF]" fill="#0084FF" stroke="none" />
|
||||
</IconCircle>
|
||||
</div>
|
||||
|
||||
{/* Ring 2 (r=150) */}
|
||||
<div className="animate-floating" style={{ animationDelay: '1s' }}>
|
||||
<IconCircle x={0} y={-150}>
|
||||
<Globe className="w-6 h-6 text-[#0078D7]" />
|
||||
</IconCircle>
|
||||
</div>
|
||||
|
||||
<div className="animate-floating" style={{ animationDelay: '0.2s' }}>
|
||||
<IconCircle x={-60} y={138}>
|
||||
<span className="text-[#6001D2] font-bold italic text-xl -ml-1">y!</span>
|
||||
</IconCircle>
|
||||
</div>
|
||||
|
||||
{/* Ring 3 (r=210) */}
|
||||
<div className="animate-floating" style={{ animationDelay: '0.8s' }}>
|
||||
<IconCircle x={-185} y={-70}>
|
||||
<AndroidIcon className="w-6 h-6" />
|
||||
</IconCircle>
|
||||
</div>
|
||||
|
||||
<div className="animate-floating" style={{ animationDelay: '1.5s' }}>
|
||||
<IconCircle x={200} y={50}>
|
||||
<Apple className="w-5.5 h-5.5 text-black" fill="black" />
|
||||
</IconCircle>
|
||||
</div>
|
||||
|
||||
{/* Ring 4 (r=270) */}
|
||||
<div className="animate-floating" style={{ animationDelay: '0.4s' }}>
|
||||
<IconCircle x={-190} y={180}>
|
||||
<ChromeIcon className="w-6.5 h-6.5" />
|
||||
</IconCircle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-center text-gray-600 mt-6 max-w-[85%] z-10 leading-[1.6]">
|
||||
Compatible with{' '}
|
||||
<span className="font-bold italic text-gray-900">Gmail, Outlook Web, LinkedIn and most web editors</span> for a
|
||||
smooth writing experience anywhere online.
|
||||
</p>
|
||||
|
||||
{/* Pagination Indicators */}
|
||||
<div className="flex gap-1.5 mt-8 z-10 mb-2">
|
||||
<div className="w-6 h-1 rounded-full bg-brand-500"></div>
|
||||
<div className="w-6 h-1 rounded-full bg-brand-200"></div>
|
||||
<div className="w-6 h-1 rounded-full bg-brand-200"></div>
|
||||
<div className="flex h-full w-full items-stretch p-8">
|
||||
<div
|
||||
className="flex flex-1 items-center justify-center overflow-hidden rounded-[5rem]"
|
||||
style={{ backgroundColor: 'rgba(101, 46, 217, 0.2)' }}
|
||||
>
|
||||
<Image src={loginBanner} alt="" fit="contain" w="82%" maw={560} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const Ring = ({ size }: { size: number }) => (
|
||||
<div
|
||||
className="absolute rounded-full border border-white/60"
|
||||
style={{ width: size, height: size, top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
|
||||
/>
|
||||
);
|
||||
@@ -3,14 +3,11 @@ import {
|
||||
FieldPasswordInput,
|
||||
Checkbox,
|
||||
Button,
|
||||
Divider,
|
||||
Select,
|
||||
Image,
|
||||
notifications,
|
||||
} from '@repo/ui/components';
|
||||
import { Lock, Apple, LogInIcon, UserRound, Globe } from 'lucide-react';
|
||||
import { GoogleIcon } from './components/google-icon';
|
||||
import { MicrosoftIcon } from './components/microsoft-icon';
|
||||
import { Lock, LogInIcon, UserRound, Globe } from 'lucide-react';
|
||||
import { RightSection } from './components/right-section';
|
||||
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { AppStorageKey, appStorage } from '../../../core/storage/local';
|
||||
@@ -167,9 +164,6 @@ export default function LoginPage() {
|
||||
label: { color: '#64748b', fontWeight: 500 },
|
||||
}}
|
||||
/>
|
||||
<a href="#" className="text-[13px] text-brand-600 font-semibold hover:underline">
|
||||
{t('forgot_password')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -182,43 +176,6 @@ export default function LoginPage() {
|
||||
>
|
||||
{t('login_button')}
|
||||
</Button>
|
||||
|
||||
<Divider
|
||||
label={t('or_login_with')}
|
||||
labelPosition="center"
|
||||
my="lg"
|
||||
styles={{ label: { color: '#94a3b8', fontSize: '12px' } }}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="md"
|
||||
className="border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
styles={{ root: { borderRadius: '10px', height: '44px' } }}
|
||||
>
|
||||
<GoogleIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="md"
|
||||
className="border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
styles={{ root: { borderRadius: '10px', height: '44px' } }}
|
||||
>
|
||||
<Apple className="w-5.5 h-5.5 text-black" fill="black" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="md"
|
||||
className="border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
styles={{ root: { borderRadius: '10px', height: '44px' } }}
|
||||
>
|
||||
<MicrosoftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Login to your account!",
|
||||
"description": "Enter your registered username and password to login!",
|
||||
"title": "Sign in to TrackGo",
|
||||
"description": "Manage sales, deliveries, and field activity from one place.",
|
||||
"username_label": "Username",
|
||||
"username_placeholder": "eg. administrator",
|
||||
"password_label": "Password",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Login ke akun Anda!",
|
||||
"description": "Masukkan username dan password yang terdaftar untuk login!",
|
||||
"title": "Masuk ke TrackGo",
|
||||
"description": "Kelola penjualan, pengiriman, dan aktivitas lapangan dalam satu tempat.",
|
||||
"username_label": "Username",
|
||||
"username_placeholder": "cth. administrator",
|
||||
"password_label": "Password",
|
||||
|
||||
@@ -5,8 +5,10 @@ import { StatusPage, AgGridProvider } from '@repo/ui/components';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import { LoadingScreen } from '../core/components/loading-screen';
|
||||
import { ComingSoonPage } from '../core/components/coming-soon-page';
|
||||
import { WEB_URL } from '../core/constants/web-url';
|
||||
import { registerClientNavigate, registerSessionLifecycle } from '../core/lib/client-navigation';
|
||||
import { useThemeStore } from '../core/stores/theme.store';
|
||||
import { initializeAndPurgeHistoryBackground } from './main/layouts/hooks/useHistoryTracker';
|
||||
import { initializeAndPurgeHistoryBackground, resetHistoryCache } from './main/layouts/hooks/useHistoryTracker';
|
||||
|
||||
const AuthModule = lazy(() => import('./auth'));
|
||||
const AppModule = lazy(() => import('./main'));
|
||||
@@ -20,7 +22,7 @@ function NotFoundPage() {
|
||||
description={t('common:systemPages.notFound.description')}
|
||||
showActionsBack
|
||||
showActionsHome
|
||||
homeUrl="/app"
|
||||
homeUrl={WEB_URL.APP}
|
||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||
/>
|
||||
@@ -36,7 +38,7 @@ function ForbiddenPage() {
|
||||
description={t('common:systemPages.forbidden.description')}
|
||||
showActionsBack
|
||||
showActionsHome
|
||||
homeUrl="/app"
|
||||
homeUrl={WEB_URL.APP}
|
||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||
/>
|
||||
@@ -61,10 +63,16 @@ const router = createBrowserRouter([
|
||||
{ path: '/403', element: <ForbiddenPage /> },
|
||||
{ path: '/maintenance', element: <MaintenancePage /> },
|
||||
{ path: '/coming-soon', element: <ComingSoonPage /> },
|
||||
{ path: '/', element: <Navigate to="/app" /> },
|
||||
{ path: '/', element: <Navigate to={WEB_URL.APP} /> },
|
||||
{ path: '*', element: <Navigate to="/404" /> },
|
||||
]);
|
||||
|
||||
registerClientNavigate((to, options) => router.navigate(to, options));
|
||||
registerSessionLifecycle({
|
||||
onEnd: resetHistoryCache,
|
||||
onStart: () => initializeAndPurgeHistoryBackground(),
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import type { PrivilegeEntity } from '@repo/ui/foundations';
|
||||
import { WEB_URL } from '../../core/constants/web-url';
|
||||
import { filterMenuByViewPrivilege } from '../../core/lib/filter-menu-by-view-privilege';
|
||||
import { firstProductPath } from '../../core/lib/first-product-path';
|
||||
import { appDatabase, AppDatabaseKey } from '../../core/storage/local';
|
||||
import { MENU_ITEMS } from './layouts/data/menu.data';
|
||||
|
||||
export function AppHomeRedirect() {
|
||||
const [to, setTo] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
||||
if (!token || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await appDatabase.getItem<{ isSuperadmin?: boolean }>(AppDatabaseKey.USER_PROFILE);
|
||||
const privileges = await appDatabase.getItem<Record<string, PrivilegeEntity>>(AppDatabaseKey.USER_PRIVILEGE);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = filterMenuByViewPrivilege(MENU_ITEMS, privileges ?? {}, Boolean(profile?.isSuperadmin));
|
||||
setTo(firstProductPath(filtered, WEB_URL.APP_HOME));
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Navigate to={to} replace />;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import ModuleLayout from './layouts/module.layout';
|
||||
import { EmbeddedComingSoonPage } from '../../core/components/coming-soon-page';
|
||||
import { GlobalCredentialChecker } from '../../core/lib/auth-guard';
|
||||
import { AppHomeRedirect } from './app-home-redirect';
|
||||
import ModuleLayout from './layouts/module.layout';
|
||||
|
||||
const ExampleModule = lazy(() => import('./modules/example'));
|
||||
const SystemSetting = lazy(() => import('./modules/system/setting'));
|
||||
@@ -11,6 +13,7 @@ const PrivilegesModule = lazy(() => import('./modules/system/privileges/presenta
|
||||
const UsersModule = lazy(() => import('./modules/system/users/presentation/factory'));
|
||||
const ConfigurationModule = lazy(() => import('./modules/configuration'));
|
||||
const SalesModule = lazy(() => import('./modules/sales'));
|
||||
const TimelineModule = lazy(() => import('./modules/field/timeline/presentation/factory'));
|
||||
const LogisticsFieldModule = lazy(() => import('./modules/field/logistics'));
|
||||
|
||||
export default function AppModule() {
|
||||
@@ -26,8 +29,10 @@ export default function AppModule() {
|
||||
<Route path="/system/users/*" element={<UsersModule />} />
|
||||
<Route path="/configuration/*" element={<ConfigurationModule />} />
|
||||
<Route path="/sales/*" element={<SalesModule />} />
|
||||
<Route path="/timeline/*" element={<TimelineModule />} />
|
||||
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
||||
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
||||
<Route path="/dashboard" element={<EmbeddedComingSoonPage />} />
|
||||
<Route path="/" element={<AppHomeRedirect />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</ModuleLayout>
|
||||
|
||||
@@ -13,8 +13,17 @@ const flatten = (items: MenuItemType[]): MenuItemType[] =>
|
||||
items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]);
|
||||
|
||||
describe('MENU_ITEMS', () => {
|
||||
it('orders top-level items as dashboard, sales, logistics, settings', () => {
|
||||
expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'sales', 'logistics', 'settings']);
|
||||
it('orders top-level items as dashboard, timeline, sales, logistics, settings', () => {
|
||||
expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'timeline', 'sales', 'logistics', 'settings']);
|
||||
});
|
||||
|
||||
it('places timeline next to dashboard instead of inside sales activities', () => {
|
||||
const timeline = findItem(MENU_ITEMS, 'timeline');
|
||||
const sales = findItem(MENU_ITEMS, 'sales');
|
||||
|
||||
expect(timeline?.path).toBe('/app/timeline/index');
|
||||
expect(timeline?.moduleKey).toBe('ADMIN.SALES.ACTIVITIES.TIMELINE');
|
||||
expect(childKeys(findItem(sales?.children ?? [], 'sales-activities'))).not.toContain('sales-timeline');
|
||||
});
|
||||
|
||||
it('nests sales as data, activities, then reports', () => {
|
||||
@@ -58,6 +67,7 @@ describe('MENU_ITEMS', () => {
|
||||
'configuration-divisions',
|
||||
'configuration-customers',
|
||||
'configuration-products',
|
||||
'configuration-company-settings',
|
||||
]);
|
||||
expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual([
|
||||
'system-users',
|
||||
|
||||
@@ -33,6 +33,13 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
icon: LayoutDashboard,
|
||||
path: '/app/dashboard',
|
||||
},
|
||||
{
|
||||
key: 'timeline',
|
||||
label: 'nav:timeline',
|
||||
icon: MapPin,
|
||||
path: '/app/timeline/index',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
|
||||
},
|
||||
{
|
||||
key: 'sales',
|
||||
label: 'nav:sales',
|
||||
@@ -50,14 +57,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:configuration-employees',
|
||||
icon: Users,
|
||||
path: '/app/sales/employees/index',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
},
|
||||
{
|
||||
key: 'sales-cycles',
|
||||
label: 'nav:sales-cycles',
|
||||
icon: Repeat,
|
||||
path: '/app/sales/cycles/index',
|
||||
moduleKey: 'SALES.CYCLE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -72,35 +79,35 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:sales-requests',
|
||||
icon: ClipboardList,
|
||||
path: '/app/sales/requests/index',
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
|
||||
},
|
||||
{
|
||||
key: 'sales-orders',
|
||||
label: 'nav:sales-orders',
|
||||
icon: Box,
|
||||
path: '/app/sales/orders/index',
|
||||
moduleKey: 'SALES.ORDER',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
|
||||
},
|
||||
{
|
||||
key: 'sales-invoices',
|
||||
label: 'nav:sales-invoices',
|
||||
icon: Receipt,
|
||||
path: '/app/sales/invoices/index',
|
||||
moduleKey: 'SALES.INVOICE',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
},
|
||||
{
|
||||
key: 'sales-payments',
|
||||
label: 'nav:sales-payments',
|
||||
icon: CreditCard,
|
||||
path: '/app/sales/payments/index',
|
||||
moduleKey: 'SALES.PAYMENT',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
|
||||
},
|
||||
{
|
||||
key: 'sales-plans',
|
||||
label: 'nav:sales-plans',
|
||||
icon: Calendar,
|
||||
path: '/app/sales/plans/index',
|
||||
moduleKey: 'SALES.PLAN',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -109,7 +116,7 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:sales-reports',
|
||||
icon: FileText,
|
||||
path: '/app/sales/reports/index',
|
||||
moduleKey: 'SALES.REPORT',
|
||||
moduleKey: 'ADMIN.SALES.REPORT',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -130,14 +137,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:configuration-employees',
|
||||
icon: Users,
|
||||
path: '/app/logistics/employees/index',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
},
|
||||
{
|
||||
key: 'logistics-cycles',
|
||||
label: 'nav:logistics-cycles',
|
||||
icon: Repeat,
|
||||
path: '/app/logistics/cycles/index',
|
||||
moduleKey: 'LOGISTICS.CYCLE',
|
||||
moduleKey: 'ADMIN.LOGISTICS.DATA.CYCLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -152,14 +159,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:logistics-packing-slips',
|
||||
icon: Package,
|
||||
path: '/app/logistics/packing-slips/index',
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
},
|
||||
{
|
||||
key: 'logistics-plans',
|
||||
label: 'nav:logistics-plans',
|
||||
icon: Calendar,
|
||||
path: '/app/logistics/plans/index',
|
||||
moduleKey: 'LOGISTICS.PLAN',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -168,7 +175,7 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:logistics-reports',
|
||||
icon: FileText,
|
||||
path: '/app/logistics/reports/index',
|
||||
moduleKey: 'LOGISTICS.REPORT',
|
||||
moduleKey: 'ADMIN.LOGISTICS.REPORT',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -189,28 +196,35 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:configuration-branches',
|
||||
icon: MapPin,
|
||||
path: '/app/configuration/branches/index',
|
||||
moduleKey: 'CONFIGURATION.BRANCH',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
},
|
||||
{
|
||||
key: 'configuration-divisions',
|
||||
label: 'nav:configuration-divisions',
|
||||
icon: Layers,
|
||||
path: '/app/configuration/divisions/index',
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
},
|
||||
{
|
||||
key: 'configuration-customers',
|
||||
label: 'nav:configuration-customers',
|
||||
icon: Users,
|
||||
path: '/app/configuration/customers/index',
|
||||
moduleKey: 'CONFIGURATION.CUSTOMER',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
},
|
||||
{
|
||||
key: 'configuration-products',
|
||||
label: 'nav:configuration-products',
|
||||
icon: Package,
|
||||
path: '/app/configuration/products/index',
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
},
|
||||
{
|
||||
key: 'configuration-company-settings',
|
||||
label: 'nav:configuration-company-settings',
|
||||
icon: Settings,
|
||||
path: '/app/configuration/company-settings/index',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -225,14 +239,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:system-users',
|
||||
icon: Users,
|
||||
path: '/app/system/users/index',
|
||||
moduleKey: 'USERS',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.USERS',
|
||||
},
|
||||
{
|
||||
key: 'system-privileges',
|
||||
label: 'nav:system-privileges',
|
||||
icon: Shield,
|
||||
path: '/app/system/privileges/index',
|
||||
moduleKey: 'PRIVILEGES',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -29,6 +29,11 @@ if (historyChannel) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Background Task: Initialization & Purge (Remain the same)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function resetHistoryCache(): void {
|
||||
memoryHistoryCache = [];
|
||||
isHistoryReady = false;
|
||||
}
|
||||
|
||||
export async function initializeAndPurgeHistoryBackground() {
|
||||
if (isHistoryReady) return;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dashboard": "Dashboard",
|
||||
"timeline": "Timeline",
|
||||
"crm": "CRM",
|
||||
"crm-leads": "Leads",
|
||||
"crm-pipelines": "Pipelines",
|
||||
@@ -55,5 +56,6 @@
|
||||
"logistics-plans": "Logistics Plans",
|
||||
"logistics-packing-slips": "Packing Slips",
|
||||
"configuration-employees": "Employees",
|
||||
"configuration-products": "Products"
|
||||
"configuration-products": "Products",
|
||||
"configuration-company-settings": "Company settings"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dashboard": "Dasbor",
|
||||
"timeline": "Timeline",
|
||||
"crm": "CRM",
|
||||
"crm-leads": "Prospek",
|
||||
"crm-pipelines": "Alur Penjualan",
|
||||
@@ -55,5 +56,6 @@
|
||||
"logistics-plans": "Rencana Logistik",
|
||||
"logistics-packing-slips": "Surat Jalan",
|
||||
"configuration-employees": "Karyawan",
|
||||
"configuration-products": "Produk"
|
||||
"configuration-products": "Produk",
|
||||
"configuration-company-settings": "Pengaturan perusahaan"
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('BranchesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new BranchesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/branches',
|
||||
moduleKey: 'CONFIGURATION.BRANCH',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
transformer: new BranchesRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { BranchEntity } from '../entities';
|
||||
|
||||
export const branchesModuleConfig: ModuleConfigEntity<BranchEntity> = {
|
||||
moduleKey: 'CONFIGURATION.BRANCH',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
translationNamespace: 'BRANCHES',
|
||||
apiUrl: '/branches',
|
||||
webUrl: '/app/configuration/branches',
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type { CompanySettingsEntity, UpdateCompanySettingsPayload } from '../domain/entities/company-settings.entity';
|
||||
|
||||
export class CompanySettingsRemoteService {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
|
||||
async get(): Promise<CompanySettingsEntity> {
|
||||
const { data } = await this.client.get<CompanySettingsEntity>('/settings');
|
||||
return data;
|
||||
}
|
||||
|
||||
async update(payload: UpdateCompanySettingsPayload): Promise<CompanySettingsEntity> {
|
||||
const { data } = await this.client.patch<CompanySettingsEntity>('/settings', payload);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
|
||||
export type CompanySettingsShellEntity = BaseEntity & { id: string };
|
||||
|
||||
export const companySettingsModuleConfig: ModuleConfigEntity<CompanySettingsShellEntity> = {
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
|
||||
translationNamespace: 'COMPANY_SETTINGS',
|
||||
apiUrl: '/settings',
|
||||
webUrl: '/app/configuration/company-settings',
|
||||
moduleCategory: 'SINGLE_PAGE',
|
||||
moduleType: 'MASTER_DATA',
|
||||
} as const;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
export type CompanySettingsEntity = {
|
||||
id: string;
|
||||
cycleStartDate: number;
|
||||
checkInRadiusMeters: number;
|
||||
gpsIntervalSeconds: number;
|
||||
checkoutWarningRadiusMeters: number;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
};
|
||||
|
||||
export type UpdateCompanySettingsPayload = {
|
||||
cycleStartDate?: string;
|
||||
checkInRadiusMeters?: number;
|
||||
gpsIntervalSeconds?: number;
|
||||
checkoutWarningRadiusMeters?: number;
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||
import { companySettingsModuleConfig, type CompanySettingsShellEntity } from '../constants/company-settings.constants';
|
||||
import { CompanySettingsRemoteService } from '../../data/company-settings.remote.service';
|
||||
|
||||
class CompanySettingsShellTransformer extends BaseDataTransformer<CompanySettingsShellEntity> {
|
||||
transformToEntity(dto: CompanySettingsShellEntity): CompanySettingsShellEntity {
|
||||
return dto;
|
||||
}
|
||||
|
||||
transformToDTO(entity: CompanySettingsShellEntity): CompanySettingsShellEntity {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
export const companySettingsDataService = new TrackGoRemoteDataServices(apiClient, {
|
||||
apiUrl: companySettingsModuleConfig.apiUrl,
|
||||
moduleKey: companySettingsModuleConfig.moduleKey,
|
||||
transformer: new CompanySettingsShellTransformer(),
|
||||
});
|
||||
|
||||
export const companySettingsRemoteService = new CompanySettingsRemoteService(apiClient);
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
|
||||
import { companySettingsDataService } from '../../domain/factories';
|
||||
import { companySettingsStore } from '../store';
|
||||
|
||||
import companySettingsEn from '../languages/en/company-settings.json';
|
||||
import companySettingsId from '../languages/id/company-settings.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/company-settings.page'));
|
||||
|
||||
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
|
||||
en: companySettingsEn,
|
||||
id: companySettingsId,
|
||||
});
|
||||
|
||||
export default function CompanySettingsModule() {
|
||||
return (
|
||||
<EnterpriseModuleProvider
|
||||
config={companySettingsModuleConfig}
|
||||
dataServices={companySettingsDataService}
|
||||
store={companySettingsStore}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/" element={<Navigate to={`${companySettingsModuleConfig.webUrl}/index`} replace />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Company settings",
|
||||
"description": "Configure cycle start date, check-in radius, and timeline tracking.",
|
||||
"fields": {
|
||||
"cycleStartDate": "Cycle start date",
|
||||
"checkInRadiusMeters": "Check-in radius (meters)",
|
||||
"gpsIntervalSeconds": "GPS interval (seconds)",
|
||||
"checkoutWarningRadiusMeters": "Checkout warning radius (meters)"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save settings"
|
||||
},
|
||||
"messages": {
|
||||
"saved": "Company settings updated.",
|
||||
"loadFailed": "Could not load company settings."
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Pengaturan perusahaan",
|
||||
"description": "Atur tanggal awal siklus, radius check-in, dan pelacakan timeline.",
|
||||
"fields": {
|
||||
"cycleStartDate": "Tanggal awal siklus",
|
||||
"checkInRadiusMeters": "Radius check-in (meter)",
|
||||
"gpsIntervalSeconds": "Interval GPS (detik)",
|
||||
"checkoutWarningRadiusMeters": "Radius peringatan checkout (meter)"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Simpan pengaturan"
|
||||
},
|
||||
"messages": {
|
||||
"saved": "Pengaturan perusahaan diperbarui.",
|
||||
"loadFailed": "Gagal memuat pengaturan perusahaan."
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, CorePageContainer, Grid, Stack, Text } from '@repo/ui/components';
|
||||
import { ModulePageHeader } from '@repo/ui/foundations';
|
||||
import { FieldDatePicker, FieldNumberInput } from '@repo/ui/form';
|
||||
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
|
||||
import { companySettingsRemoteService } from '../../domain/factories';
|
||||
|
||||
import companySettingsEn from '../languages/en/company-settings.json';
|
||||
import companySettingsId from '../languages/id/company-settings.json';
|
||||
|
||||
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
|
||||
en: companySettingsEn,
|
||||
id: companySettingsId,
|
||||
});
|
||||
|
||||
type CompanySettingsForm = {
|
||||
cycleStartDate: string;
|
||||
checkInRadiusMeters: number;
|
||||
gpsIntervalSeconds: number;
|
||||
checkoutWarningRadiusMeters: number;
|
||||
};
|
||||
|
||||
function unixDayToIsoDate(unixMs: number): string {
|
||||
const date = new Date(unixMs);
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
const { t } = useTranslation(companySettingsModuleConfig.translationNamespace);
|
||||
const { t: tNav } = useTranslation('nav');
|
||||
const form = useForm<CompanySettingsForm>();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const settings = await companySettingsRemoteService.get();
|
||||
form.reset({
|
||||
cycleStartDate: unixDayToIsoDate(settings.cycleStartDate),
|
||||
checkInRadiusMeters: settings.checkInRadiusMeters,
|
||||
gpsIntervalSeconds: settings.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: settings.checkoutWarningRadiusMeters,
|
||||
});
|
||||
} catch {
|
||||
setErrorMessage(t('messages.loadFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [form, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
await companySettingsRemoteService.update(values);
|
||||
setSuccessMessage(t('messages.saved'));
|
||||
} catch {
|
||||
setErrorMessage(t('messages.loadFailed'));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<CorePageContainer>
|
||||
<ModulePageHeader
|
||||
icon={Settings}
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
moduleKey={companySettingsModuleConfig.moduleKey}
|
||||
breadcrumbs={[
|
||||
{ label: tNav('settings'), type: 'text' },
|
||||
{ label: tNav('data'), type: 'text' },
|
||||
{ label: t('title'), type: 'text' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<Card withBorder padding="xl" radius="md">
|
||||
<Stack gap="md" component="form" onSubmit={onSubmit}>
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldDatePicker control={form.control} name="cycleStartDate" label={t('fields.cycleStartDate')} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldNumberInput
|
||||
control={form.control}
|
||||
name="checkInRadiusMeters"
|
||||
label={t('fields.checkInRadiusMeters')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldNumberInput
|
||||
control={form.control}
|
||||
name="gpsIntervalSeconds"
|
||||
label={t('fields.gpsIntervalSeconds')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldNumberInput
|
||||
control={form.control}
|
||||
name="checkoutWarningRadiusMeters"
|
||||
label={t('fields.checkoutWarningRadiusMeters')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{errorMessage ? (
|
||||
<Text size="sm" c="red">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
{successMessage ? (
|
||||
<Text size="sm" c="teal">
|
||||
{successMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" loading={isLoading}>
|
||||
{t('actions.save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</FormProvider>
|
||||
</CorePageContainer>
|
||||
);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import type { CompanySettingsShellEntity } from '../../domain/constants/company-settings.constants';
|
||||
|
||||
export const companySettingsStore = create<EnterpriseModuleState<CompanySettingsShellEntity>>((set) => ({
|
||||
metaData: { limit: 15 },
|
||||
setMetaData: (data) => set({ metaData: data }),
|
||||
filterData: {},
|
||||
setFilterData: (data) => set({ filterData: data }),
|
||||
selectedRows: [],
|
||||
setSelectedRows: (rows) => set({ selectedRows: rows }),
|
||||
privileges: [],
|
||||
setPrivileges: (privileges) => set({ privileges }),
|
||||
tableConfig: null,
|
||||
setTableConfig: (config) => set({ tableConfig: config }),
|
||||
}));
|
||||
+1
-1
@@ -42,7 +42,7 @@ describe('CustomersRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new CustomersRemoteDataServices(httpClient, {
|
||||
apiUrl: '/customers',
|
||||
moduleKey: 'CONFIGURATION.CUSTOMER',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
transformer: new CustomersRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { CustomerEntity } from '../entities';
|
||||
|
||||
export const customersModuleConfig: ModuleConfigEntity<CustomerEntity> = {
|
||||
moduleKey: 'CONFIGURATION.CUSTOMER',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
translationNamespace: 'CUSTOMERS',
|
||||
apiUrl: '/customers',
|
||||
webUrl: '/app/configuration/customers',
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ describe('DivisionsRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new DivisionsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/divisions',
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
transformer: new DivisionsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { DivisionEntity } from '../entities';
|
||||
|
||||
export const divisionsModuleConfig: ModuleConfigEntity<DivisionEntity> = {
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
translationNamespace: 'DIVISIONS',
|
||||
apiUrl: '/divisions',
|
||||
webUrl: '/app/configuration/divisions',
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('EmployeesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new EmployeesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/employees',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
transformer: new EmployeesRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ describe('employee purpose helpers', () => {
|
||||
});
|
||||
|
||||
it('keeps a shared employee privilege key', () => {
|
||||
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
|
||||
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
|
||||
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
|
||||
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import type { EmployeeEntity } from '../entities';
|
||||
|
||||
export function createEmployeeModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<EmployeeEntity> {
|
||||
return {
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
translationNamespace: 'EMPLOYEES',
|
||||
apiUrl: '/employees',
|
||||
webUrl: `/app/${purpose}/employees`,
|
||||
|
||||
@@ -6,6 +6,7 @@ const DivisionsModule = lazy(() => import('./divisions/presentation/factory'));
|
||||
const BranchesModule = lazy(() => import('./branches/presentation/factory'));
|
||||
const CustomersModule = lazy(() => import('./customers/presentation/factory'));
|
||||
const ProductsModule = lazy(() => import('./products/presentation/factory'));
|
||||
const CompanySettingsModule = lazy(() => import('./company-settings/presentation/factory'));
|
||||
|
||||
export default function ConfigurationModule() {
|
||||
return (
|
||||
@@ -14,6 +15,7 @@ export default function ConfigurationModule() {
|
||||
<Route path="/branches/*" element={<BranchesModule />} />
|
||||
<Route path="/customers/*" element={<CustomersModule />} />
|
||||
<Route path="/products/*" element={<ProductsModule />} />
|
||||
<Route path="/company-settings/*" element={<CompanySettingsModule />} />
|
||||
<Route path="/employees/*" element={<Navigate to={`${WEB_URL.SALES_EMPLOYEES}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('ProductsRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new ProductsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/products',
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
transformer: new ProductsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { ProductEntity } from '../entities';
|
||||
|
||||
export const productsModuleConfig: ModuleConfigEntity<ProductEntity> = {
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
translationNamespace: 'PRODUCTS',
|
||||
apiUrl: '/products',
|
||||
webUrl: '/app/configuration/products',
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('CyclesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new CyclesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/cycles',
|
||||
moduleKey: 'SALES.CYCLE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
|
||||
transformer: new CyclesRemoteDataTransformer('sales'),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CycleEntity, CycleWeekdayRow } from '../entities';
|
||||
|
||||
export function createCycleModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<CycleEntity> {
|
||||
return {
|
||||
moduleKey: purpose === 'sales' ? 'SALES.CYCLE' : 'LOGISTICS.CYCLE',
|
||||
moduleKey: purpose === 'sales' ? 'ADMIN.SALES.DATA.CYCLE' : 'ADMIN.LOGISTICS.DATA.CYCLE',
|
||||
translationNamespace: 'CYCLES',
|
||||
apiUrl: '/cycles',
|
||||
webUrl: `/app/${purpose}/cycles`,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
export type ReportShellEntity = BaseEntity & { id: string };
|
||||
|
||||
export const logisticsReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
|
||||
moduleKey: 'LOGISTICS.REPORT',
|
||||
moduleKey: 'ADMIN.LOGISTICS.REPORT',
|
||||
translationNamespace: 'LOGISTICS_REPORTS',
|
||||
apiUrl: '/reports',
|
||||
webUrl: '/app/logistics/reports',
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { PackingSlipEntity } from '../entities';
|
||||
|
||||
export const packingSlipsModuleConfig: ModuleConfigEntity<PackingSlipEntity> = {
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
translationNamespace: 'PACKING_SLIPS',
|
||||
apiUrl: '/packing-slips',
|
||||
webUrl: '/app/logistics/packing-slips',
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('PlansRemoteDataServices', () => {
|
||||
httpClient,
|
||||
{
|
||||
apiUrl: '/plans',
|
||||
moduleKey: 'SALES.PLAN',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||
transformer: new PlansRemoteDataTransformer('sales'),
|
||||
},
|
||||
'sales',
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PlanEntity } from '../entities';
|
||||
|
||||
export function createPlanModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<PlanEntity> {
|
||||
return {
|
||||
moduleKey: purpose === 'sales' ? 'SALES.PLAN' : 'LOGISTICS.PLAN',
|
||||
moduleKey: purpose === 'sales' ? 'ADMIN.SALES.ACTIVITIES.PLAN' : 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
|
||||
translationNamespace: 'PLANS',
|
||||
apiUrl: '/plans',
|
||||
webUrl: `/app/${purpose}/plans`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { RouteGeometry } from '../../../cycles/domain/entities';
|
||||
export interface PlanDestinationEntity {
|
||||
id?: string;
|
||||
customerId: string;
|
||||
customer?: RelationRef | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
|
||||
+47
@@ -29,6 +29,39 @@ describe('PlansRemoteDataTransformer', () => {
|
||||
expect(entity.customers).toEqual([{ id: 'cus-1' }]);
|
||||
});
|
||||
|
||||
it('maps nested destination.customer onto customers for the edit form', () => {
|
||||
const entity = salesTransformer.transformToEntity({
|
||||
id: 'plan-1',
|
||||
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
purpose: 'sales',
|
||||
date: Date.UTC(2026, 0, 12),
|
||||
startBranch: { id: 'br-1', code: 'BDG', name: 'Bandung' },
|
||||
endBranch: { id: 'br-2', code: 'BDG', name: 'Bandung' },
|
||||
destinations: [
|
||||
{
|
||||
id: 'd-1',
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme Corp' },
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
invoices: [{ id: 'inv-1', code: 'SI-001' }],
|
||||
packingSlips: [],
|
||||
status: 'active',
|
||||
} as any);
|
||||
|
||||
expect(entity.customers).toEqual([{ id: 'cus-1', code: 'C1', name: 'Acme Corp' }]);
|
||||
expect(entity.destinations).toEqual([
|
||||
{
|
||||
id: 'd-1',
|
||||
customerId: 'cus-1',
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme Corp' },
|
||||
sortOrder: 0,
|
||||
},
|
||||
]);
|
||||
expect(entity.invoices).toEqual([{ id: 'inv-1', code: 'SI-001' }]);
|
||||
expect(entity.invoiceIds).toEqual(['inv-1']);
|
||||
});
|
||||
|
||||
it('injects purpose and sales invoice attachments on create', () => {
|
||||
const payload = salesTransformer.transformCreatePayload({
|
||||
employee: { id: 'emp-1' },
|
||||
@@ -63,4 +96,18 @@ describe('PlansRemoteDataTransformer', () => {
|
||||
expect(payload.packingSlipIds).toEqual(['ps-1']);
|
||||
expect(payload).not.toHaveProperty('invoiceIds');
|
||||
});
|
||||
|
||||
it('does not resurrect invoices when the form list is emptied', () => {
|
||||
const payload = salesTransformer.transformEditPayload({
|
||||
employee: { id: 'emp-1' },
|
||||
date: '2026-01-12',
|
||||
startBranch: { id: 'br-1' },
|
||||
endBranch: { id: 'br-2' },
|
||||
customers: [{ id: 'cus-1' }],
|
||||
invoices: [],
|
||||
invoiceIds: ['inv-1'],
|
||||
} as any);
|
||||
|
||||
expect(payload.invoiceIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+53
-9
@@ -2,7 +2,8 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
||||
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
|
||||
import type { PlanEntity } from '../entities';
|
||||
import type { RelationRef } from '../../../../../../../core/domain/relation-ref';
|
||||
import type { PlanDestinationEntity, PlanEntity } from '../entities';
|
||||
|
||||
function relationId(value: unknown): string | undefined {
|
||||
if (value && typeof value === 'object' && 'id' in value) {
|
||||
@@ -17,12 +18,55 @@ function relationIds(value: unknown): string[] {
|
||||
return value.map((item) => relationId(item)).filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
function relationRef(value: unknown): RelationRef | undefined {
|
||||
const id = relationId(value);
|
||||
if (!id || !value || typeof value !== 'object') return undefined;
|
||||
const row = value as { code?: unknown; name?: unknown };
|
||||
return {
|
||||
id,
|
||||
...(typeof row.code === 'string' ? { code: row.code } : {}),
|
||||
...(typeof row.name === 'string' ? { name: row.name } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function customerFromDestination(destination: unknown): RelationRef | undefined {
|
||||
if (!destination || typeof destination !== 'object') return undefined;
|
||||
const row = destination as { customer?: unknown; customerId?: unknown };
|
||||
if (row.customerId != null && row.customerId !== '') {
|
||||
return relationRef(row.customer) ?? { id: String(row.customerId) };
|
||||
}
|
||||
return relationRef(row.customer);
|
||||
}
|
||||
|
||||
function mapDestinations(destinations: unknown): PlanDestinationEntity[] {
|
||||
if (!Array.isArray(destinations)) return [];
|
||||
return destinations.map((destination, index) => {
|
||||
const row = destination && typeof destination === 'object' ? (destination as Record<string, unknown>) : {};
|
||||
const customer = customerFromDestination(destination);
|
||||
return {
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
customerId: customer?.id ?? '',
|
||||
customer: customer ?? null,
|
||||
sortOrder: typeof row.sortOrder === 'number' ? row.sortOrder : index,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity> {
|
||||
constructor(private readonly purpose: FieldPurpose) {
|
||||
super();
|
||||
}
|
||||
|
||||
transformToEntity(dto: PlanEntity): PlanEntity {
|
||||
const destinations = mapDestinations(dto.destinations);
|
||||
const customers =
|
||||
Array.isArray(dto.customers) && dto.customers.length > 0
|
||||
? dto.customers
|
||||
: destinations
|
||||
.map((destination) => destination.customer)
|
||||
.filter((customer): customer is RelationRef => Boolean(customer?.id));
|
||||
const invoices = dto.invoices ?? (dto.invoiceIds ?? []).map((id) => ({ id }));
|
||||
const packingSlips = dto.packingSlips ?? (dto.packingSlipIds ?? []).map((id) => ({ id }));
|
||||
return {
|
||||
id: dto.id,
|
||||
employeeId: dto.employeeId,
|
||||
@@ -34,12 +78,12 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
|
||||
endBranchId: dto.endBranchId,
|
||||
endBranch: dto.endBranch ?? (dto.endBranchId ? { id: dto.endBranchId } : null),
|
||||
routeGeometry: dto.routeGeometry ?? null,
|
||||
destinations: dto.destinations ?? [],
|
||||
customers: dto.customers ?? (dto.destinations ?? []).map((destination) => ({ id: destination.customerId })),
|
||||
invoiceIds: dto.invoiceIds ?? [],
|
||||
invoices: dto.invoices ?? (dto.invoiceIds ?? []).map((id) => ({ id })),
|
||||
packingSlipIds: dto.packingSlipIds ?? [],
|
||||
packingSlips: dto.packingSlips ?? (dto.packingSlipIds ?? []).map((id) => ({ id })),
|
||||
destinations,
|
||||
customers,
|
||||
invoiceIds: dto.invoiceIds ?? relationIds(invoices),
|
||||
invoices,
|
||||
packingSlipIds: dto.packingSlipIds ?? relationIds(packingSlips),
|
||||
packingSlips,
|
||||
status: dto.status,
|
||||
createdAt: dto.createdAt,
|
||||
updatedAt: dto.updatedAt,
|
||||
@@ -63,9 +107,9 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
|
||||
customerIds,
|
||||
};
|
||||
if (this.purpose === 'sales') {
|
||||
payload.invoiceIds = relationIds(entity.invoices).length ? relationIds(entity.invoices) : entity.invoiceIds;
|
||||
payload.invoiceIds = Array.isArray(entity.invoices) ? relationIds(entity.invoices) : entity.invoiceIds;
|
||||
} else {
|
||||
payload.packingSlipIds = relationIds(entity.packingSlips).length
|
||||
payload.packingSlipIds = Array.isArray(entity.packingSlips)
|
||||
? relationIds(entity.packingSlips)
|
||||
: entity.packingSlipIds;
|
||||
}
|
||||
|
||||
+8
-17
@@ -26,13 +26,13 @@ import { loadCustomerOptions } from '../../../../shared/load-customer-options';
|
||||
import type { PlansRemoteDataServices } from '../../../data/plan.remote.service';
|
||||
import type { PlanEntity } from '../../../domain/entities';
|
||||
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
|
||||
import { FormDocumentsPreview } from '../form-component/form-documents-preview';
|
||||
|
||||
export function DetailGeneral() {
|
||||
const { detailData, reload } = useDetailPageContext<PlanEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const { dataServices } = useEnterpriseModuleDataServiceContext<PlanEntity, PlansRemoteDataServices>();
|
||||
const data = detailData;
|
||||
const attachments = data?.purpose === 'sales' ? data?.invoices : data?.packingSlips;
|
||||
const destinationForm = useForm<{ customer: CustomerEntity | null }>({ defaultValues: { customer: null } });
|
||||
|
||||
const handleAdd = destinationForm.handleSubmit(async (values) => {
|
||||
@@ -92,9 +92,9 @@ export function DetailGeneral() {
|
||||
</Text>
|
||||
<Stack gap="sm" mb="md">
|
||||
{(data?.destinations ?? []).map((destination, index) => (
|
||||
<Group key={destination.id ?? destination.customerId} justify="space-between">
|
||||
<Group key={destination.id ?? `${destination.customerId}-${index}`} justify="space-between">
|
||||
<Text size="sm">
|
||||
{index + 1}. {destination.customerId}
|
||||
{index + 1}. {relationLabel(destination.customer) || destination.customerId}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -127,20 +127,11 @@ export function DetailGeneral() {
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{attachments?.length ? (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_attachments')}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{attachments.map((item) => (
|
||||
<Text key={item.id} size="sm">
|
||||
{relationLabel(item) || item.id}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
{data?.purpose === 'sales' ? (
|
||||
<FormDocumentsPreview kind="invoice" items={data.invoices ?? []} customers={data.customers} />
|
||||
) : (
|
||||
<FormDocumentsPreview kind="packingSlip" items={data?.packingSlips ?? []} customers={data?.customers} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-1
@@ -35,13 +35,20 @@ async function fetchPackingSlip(id: string): Promise<PackingSlipEntity | null> {
|
||||
return unwrapEntity<PackingSlipEntity>(await packingSlipsModuleDataService.getOne(id));
|
||||
}
|
||||
|
||||
type PlanDocumentPreviewItem = {
|
||||
id?: string | number;
|
||||
code?: string | null;
|
||||
name?: string;
|
||||
customer?: { id?: string | number; code?: string | null; name?: string } | null;
|
||||
};
|
||||
|
||||
export function FormDocumentsPreview({
|
||||
kind,
|
||||
items,
|
||||
customers = [],
|
||||
}: {
|
||||
kind: 'invoice' | 'packingSlip';
|
||||
items: Array<SalesInvoiceEntity | PackingSlipEntity>;
|
||||
items: PlanDocumentPreviewItem[];
|
||||
customers?: Array<{ id?: string | number; code?: string | null; name?: string }>;
|
||||
}) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
+9
-1
@@ -10,6 +10,7 @@ export function useHydratedRecords<T extends { id?: string | number }>(
|
||||
const ids = selectionIds(items);
|
||||
const idsKey = ids.join(',');
|
||||
const [details, setDetails] = useState<Record<string, T>>({});
|
||||
const [settled, setSettled] = useState<Record<string, true>>({});
|
||||
const itemsRef = useRef(items);
|
||||
const fetchOneRef = useRef(fetchOne);
|
||||
const shouldFetchRef = useRef(shouldFetch);
|
||||
@@ -43,6 +44,13 @@ export function useHydratedRecords<T extends { id?: string | number }>(
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSettled((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const row of rows) {
|
||||
next[row.id] = true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -52,7 +60,7 @@ export function useHydratedRecords<T extends { id?: string | number }>(
|
||||
|
||||
const pending = items.some((item) => {
|
||||
const id = item.id == null ? '' : String(item.id);
|
||||
return Boolean(id) && shouldFetch(item) && !details[id];
|
||||
return Boolean(id) && shouldFetch(item) && !details[id] && !settled[id];
|
||||
});
|
||||
|
||||
return { records: mergeHydrated(items, details), pending };
|
||||
|
||||
@@ -5,12 +5,12 @@ import type { LookupEntity } from './lookup.entity';
|
||||
|
||||
export const salesInvoicesDataService = new LookupRemoteDataServices(apiClient, {
|
||||
apiUrl: '/sales-invoices',
|
||||
moduleKey: 'SALES.INVOICE',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
});
|
||||
|
||||
export const packingSlipsDataService = new LookupRemoteDataServices(apiClient, {
|
||||
apiUrl: '/packing-slips',
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
});
|
||||
|
||||
export const loadSalesInvoiceOptions = createOptionLoader<LookupEntity>((config) =>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type { TimelineDayEntity } from '../domain/entities/timeline.entity';
|
||||
|
||||
export type TimelineQuery = {
|
||||
date?: string;
|
||||
employeeId?: string;
|
||||
};
|
||||
|
||||
export class TimelineRemoteService {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
|
||||
async getDay(query: TimelineQuery = {}): Promise<TimelineDayEntity> {
|
||||
const { data } = await this.client.get<TimelineDayEntity>('/timeline', {
|
||||
params: query,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
|
||||
export type TimelineShellEntity = BaseEntity & { id: string };
|
||||
|
||||
export const salesTimelineModuleConfig: ModuleConfigEntity<TimelineShellEntity> = {
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
|
||||
translationNamespace: 'SALES_TIMELINE',
|
||||
apiUrl: '/timeline',
|
||||
webUrl: '/app/timeline',
|
||||
moduleCategory: 'SINGLE_PAGE',
|
||||
moduleType: 'TRANSACTION',
|
||||
} as const;
|
||||
@@ -0,0 +1,32 @@
|
||||
export type TimelineRelation = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TimelineFootprintEntity = {
|
||||
id: string;
|
||||
employee: TimelineRelation;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
recordedAt: number;
|
||||
};
|
||||
|
||||
export type TimelineActivityEntity = {
|
||||
id: string;
|
||||
employee: TimelineRelation;
|
||||
customer: TimelineRelation | null;
|
||||
visitId: string | null;
|
||||
type: string;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
recordedAt: number;
|
||||
};
|
||||
|
||||
export type TimelineDayEntity = {
|
||||
date: string;
|
||||
footprints: TimelineFootprintEntity[];
|
||||
activities: TimelineActivityEntity[];
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||
import { salesTimelineModuleConfig, type TimelineShellEntity } from '../constants/timeline.constants';
|
||||
import { TimelineRemoteService } from '../../data/timeline.remote.service';
|
||||
|
||||
class TimelineShellTransformer extends BaseDataTransformer<TimelineShellEntity> {
|
||||
transformToEntity(dto: TimelineShellEntity): TimelineShellEntity {
|
||||
return dto;
|
||||
}
|
||||
|
||||
transformToDTO(entity: TimelineShellEntity): TimelineShellEntity {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
export const salesTimelineDataService = new TrackGoRemoteDataServices(apiClient, {
|
||||
apiUrl: salesTimelineModuleConfig.apiUrl,
|
||||
moduleKey: salesTimelineModuleConfig.moduleKey,
|
||||
transformer: new TimelineShellTransformer(),
|
||||
});
|
||||
|
||||
export const timelineRemoteService = new TimelineRemoteService(apiClient);
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { Avatar, Badge, Box, Card, Group, Stack, Text, Timeline, UnstyledButton } from '@repo/ui/components';
|
||||
import { formatClock, type TimelineActivityGroup } from './timeline-helpers';
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function TimelineActivityList({
|
||||
groups,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
activityLabel,
|
||||
emptyLabel,
|
||||
ungroupedLabel,
|
||||
}: {
|
||||
groups: TimelineActivityGroup[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
activityLabel: (type: string) => string;
|
||||
emptyLabel: string;
|
||||
ungroupedLabel: string;
|
||||
}) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="md">
|
||||
{emptyLabel}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{groups.map((group) => {
|
||||
const selected = group.key === selectedKey;
|
||||
const title = group.key === 'ungrouped' ? ungroupedLabel : group.title;
|
||||
const first = group.activities[0];
|
||||
const last = group.activities[group.activities.length - 1];
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={group.key}
|
||||
withBorder
|
||||
padding="md"
|
||||
radius="md"
|
||||
shadow={selected ? 'sm' : undefined}
|
||||
style={{
|
||||
borderColor: selected ? 'var(--mantine-color-blue-filled)' : 'var(--mantine-color-default-border)',
|
||||
borderWidth: selected ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
<UnstyledButton w="100%" onClick={() => onSelect(group.key)}>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatClock(group.firstRecordedAt)}
|
||||
{first && last && first.id !== last.id ? ` → ${formatClock(group.lastRecordedAt)}` : ''}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color={group.isOnTheWay ? 'blue' : 'teal'} tt="none">
|
||||
{activityLabel(group.lastType)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{selected ? (
|
||||
<Stack gap="sm" mt="md">
|
||||
<Group gap="sm">
|
||||
<Avatar radius="xl" size="md" color="blue">
|
||||
{initials(group.employeeName)}
|
||||
</Avatar>
|
||||
<Text size="sm" fw={500}>
|
||||
{group.employeeName}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box bg="var(--mantine-color-blue-light)" p="sm" bdrs="md">
|
||||
<Timeline active={group.activities.length - 1} bulletSize={12} lineWidth={2} color="blue">
|
||||
{group.activities.map((item) => (
|
||||
<Timeline.Item key={item.id} title={activityLabel(item.type)}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatClock(item.recordedAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Paper, ScrollArea, SegmentedControl, Stack, Text, TextInput } from '@repo/ui/components';
|
||||
import { Search } from 'lucide-react';
|
||||
import { TimelineActivityList } from './timeline-activity-list';
|
||||
import type { TimelineActivityGroup, TimelineActivityTab } from './timeline-helpers';
|
||||
|
||||
export function TimelineActivityPanel({
|
||||
title,
|
||||
search,
|
||||
searchPlaceholder,
|
||||
onSearchChange,
|
||||
tab,
|
||||
onTabChange,
|
||||
onTheWayLabel,
|
||||
completedLabel,
|
||||
filters,
|
||||
groups,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
activityLabel,
|
||||
emptyLabel,
|
||||
ungroupedLabel,
|
||||
errorMessage,
|
||||
}: {
|
||||
title: string;
|
||||
search: string;
|
||||
searchPlaceholder: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
tab: TimelineActivityTab;
|
||||
onTabChange: (value: TimelineActivityTab) => void;
|
||||
onTheWayLabel: string;
|
||||
completedLabel: string;
|
||||
filters: ReactNode;
|
||||
groups: TimelineActivityGroup[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
activityLabel: (type: string) => string;
|
||||
emptyLabel: string;
|
||||
ungroupedLabel: string;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="lg"
|
||||
p="md"
|
||||
h="100%"
|
||||
style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
>
|
||||
<Stack gap="md" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Text fw={700} size="xl">
|
||||
{title}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.currentTarget.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
leftSection={<Search size={16} />}
|
||||
/>
|
||||
{filters}
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={tab}
|
||||
onChange={(value) => onTabChange(value as TimelineActivityTab)}
|
||||
data={[
|
||||
{ label: onTheWayLabel, value: 'on_the_way' },
|
||||
{ label: completedLabel, value: 'completed' },
|
||||
]}
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text c="red" size="sm">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollArea flex={1} type="scroll" offsetScrollbars>
|
||||
<TimelineActivityList
|
||||
groups={groups}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={onSelect}
|
||||
activityLabel={activityLabel}
|
||||
emptyLabel={emptyLabel}
|
||||
ungroupedLabel={ungroupedLabel}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user