10 Commits
Author SHA1 Message Date
shancheas caa7d7afe9 feat: enhance documentation and localization for TrackGo
- Updated user documentation to include new sections for mobile and web functionalities, ensuring comprehensive guidance for users in both English and Bahasa Indonesia.
- Added new pages for mobile check-in, daily work, and web import processes, improving clarity and usability for field users.
- Revised the VitePress configuration to support dual language navigation and sidebar structures, enhancing user experience across different locales.
- Introduced a new developer sidebar for better access to technical documentation, streamlining the development process.

These changes significantly improve the accessibility and organization of documentation, providing a better experience for both users and developers.
2026-09-04 16:29:03 +07:00
shancheas 82bbb91992 feat: add lucide-vue-next and plantuml-encoder dependencies, update documentation structure for TrackGo
- Added `lucide-vue-next` and `plantuml-encoder` as dependencies in the project for enhanced icon usage and diagram support.
- Updated the documentation to reflect the new structure, including user guides for TrackGo, emphasizing the separation of web and mobile functionalities.
- Revised the main index and user documentation to provide clearer navigation and improved user experience.
- Introduced new mobile documentation covering check-in and daily work processes, enhancing guidance for field users.

These changes improve the overall functionality and usability of the TrackGo platform, ensuring a better experience for both web and mobile users.
2026-09-04 16:19:56 +07:00
shancheas 2445df8e20 refactor: update documentation structure and guidelines for TrackGo
- Revised the `doc-updater` documentation to clarify the separation between user and technical documentation, specifying locations and audiences for each type.
- Introduced new rules for authoring user documentation in VitePress, emphasizing the exclusion of technical content and the use of PlantUML for diagrams.
- Added a new `documentation.mdc` file to outline the documentation split and rules for maintaining clarity and consistency across the codebase.
- Updated existing documentation to reflect the new guidelines, ensuring a streamlined approach to documentation management.

These changes enhance the clarity and organization of documentation efforts, improving the overall user experience and maintainability of the codebase.
2026-09-04 15:54:48 +07:00
shancheas 78a1a905d8 feat: update login component with new branding and remove unused assets
- Replaced the login heading from "Login to your account!" to "Sign in to TrackGo" for improved branding consistency.
- Updated the login component to include a new login banner image.
- Removed unused icon components (AndroidIcon, ChromeIcon, GoogleIcon, MicrosoftIcon, IconCircle, and Ring) to streamline the codebase.
- Adjusted language files for English and Indonesian to reflect the new login title and description.

These changes enhance the user experience by aligning the login interface with the updated branding and improving code maintainability.
2026-09-03 14:43:42 +07:00
shancheas b0090bc15f feat: implement web URL constants and enhance navigation handling
- Introduced a centralized `WEB_URL` constant for managing application routes, improving maintainability and readability across components.
- Updated various components, including login, auth, and main app modules, to utilize the new `WEB_URL` constants for navigation, ensuring consistency in route management.
- Added a new `AppHomeRedirect` component to streamline user redirection based on privileges, enhancing user experience.
- Implemented client-side navigation functions to prevent full page reloads, improving performance and user interaction.
- Added tests for new functionalities, ensuring reliability in navigation and URL handling.

These changes significantly enhance the application's routing structure and navigation efficiency, providing a more cohesive user experience.
2026-09-02 11:17:51 +07:00
shancheas f5c1b7430a refactor: simplify login component imports and remove unused dependencies
- Removed the `Divider` import and unnecessary icons (`GoogleIcon`, `MicrosoftIcon`) from the login component, streamlining the code.
- Improved code readability by consolidating imports, enhancing maintainability.

These changes contribute to a cleaner and more efficient login component, making it easier for developers to work with the codebase.
2026-09-02 08:40:08 +07:00
shancheas e316afa51d refactor: clean up code formatting and improve readability
- Standardized table formatting in `api.md` for privilege keys, enhancing clarity.
- Removed unnecessary line breaks and improved inline formatting in various components, including `right-section.tsx`, `company-settings.remote.service.ts`, and `timeline-helpers.tsx`, to streamline code readability.
- Updated test cases in `privilege-key-hierarchy.test.ts` and `filter-menu-by-view-privilege.test.ts` for better alignment and consistency in assertions.

These changes improve the overall code quality and maintainability, making it easier for developers to navigate and understand the codebase.
2026-09-02 08:14:37 +07:00
shancheas 87bfe50f0e feat: add sales timeline module and company settings configuration
- Introduced a new sales timeline module with routes and lazy loading for efficient loading.
- Updated privilege keys in `api.md` to include `ADMIN.SALES.ACTIVITIES.TIMELINE` for access control.
- Enhanced menu data to include the timeline option, improving navigation.
- Added company settings module with configuration options for cycle start date and check-in radius.
- Implemented remote services and data handling for company settings, ensuring accurate data management.
- Enhanced language support for both English and Indonesian in navigation and company settings.

These changes significantly improve the application's functionality by adding a timeline feature for sales activities and a comprehensive settings module for company configurations, enhancing user experience and data management.
2026-09-01 20:36:57 +07:00
shancheas 4c643547b6 feat: enhance plan entity and transformer for customer handling
- Added optional `customer` field to `PlanDestinationEntity` to improve customer association.
- Updated `PlansRemoteDataTransformer` to map nested customer data from destinations, ensuring accurate customer representation in transformed entities.
- Enhanced `FormDocumentsPreview` to display customer information alongside documents, improving clarity in document management.
- Introduced new tests for customer mapping in transformers, ensuring reliability and correctness in data handling.

These changes significantly improve the handling of customer data within plans, enhancing user experience and data integrity.
2026-09-01 19:25:29 +07:00
shancheas 6a54aa6c50 feat: update privilege keys and module configurations for enhanced access control
- Refactored privilege keys in `api.md` to use a more structured naming convention, aligning with the new `Group.Parent.Module` format.
- Updated various module configurations across the application to reflect the new privilege key structure, ensuring consistent access control.
- Removed deprecated keys and streamlined the privilege management process, enhancing clarity and maintainability.
- Added new tests for privilege key parsing and grouping functionalities to ensure reliability and correctness.

These changes significantly improve the application's privilege management system, providing a clearer structure for access control and enhancing overall security.
2026-09-01 13:15:12 +07:00
153 changed files with 4902 additions and 1068 deletions
+26 -31
View File
@@ -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
+25
View File
@@ -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/**`).
+3 -2
View File
@@ -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`.
+93
View File
@@ -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
```
+5 -5
View File
@@ -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).
+31 -24
View File
@@ -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.
+2
View File
@@ -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",
+161 -76
View File
@@ -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),
+4
View File
@@ -0,0 +1,4 @@
declare module 'plantuml-encoder' {
export function encode(source: string): string;
export function decode(encoded: string): string;
}
+25
View File
@@ -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);
};
}
@@ -114,3 +114,201 @@
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 -186
View File
@@ -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 requests 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 invoices status by putting it on a sales plan
- Changing an invoices 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 documents status changes after you act, open that document again to see the latest value.
This guide moved to [Sales workflow](/user/web/sales-workflow).
+14 -7
View File
@@ -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)
+75
View File
@@ -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>
+60
View File
@@ -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).
+54
View File
@@ -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: 332 karakter. Kata sandi: 872 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
+29
View File
@@ -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.
+40
View File
@@ -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).
+68
View File
@@ -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.
+78
View File
@@ -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
+38
View File
@@ -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
View File
@@ -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
+60
View File
@@ -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
+41
View File
@@ -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).
+55
View File
@@ -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: 332 characters. Password: 872 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
+29
View File
@@ -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.
+40
View File
@@ -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**).
+74
View File
@@ -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).
+68
View File
@@ -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.
+78
View File
@@ -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
+38
View File
@@ -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

+4 -4
View File
@@ -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();
});
});
+2 -1
View File
@@ -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%)' }}
/>
);
+1 -44
View File
@@ -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",
+12 -4
View File
@@ -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 />;
}
+7 -2
View File
@@ -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"
}
@@ -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(),
});
});
@@ -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',
@@ -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;
}
}
@@ -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;
@@ -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;
};
@@ -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);
@@ -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>
);
}
@@ -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."
}
}
@@ -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."
}
}
@@ -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>
);
}
@@ -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 }),
}));
@@ -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(),
});
});
@@ -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',
@@ -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(),
});
});
@@ -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',
@@ -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(),
});
});
@@ -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');
});
});
@@ -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>
@@ -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(),
});
});
@@ -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`,
@@ -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',
@@ -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;
}
@@ -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([]);
});
});
@@ -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;
}
@@ -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>
);
}
@@ -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();
@@ -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);
@@ -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>
);
}
@@ -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