enh
133
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
# .env
|
||||||
|
# .env.development.local
|
||||||
|
# .env.test.local
|
||||||
|
# .env.production.local
|
||||||
|
# .env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
|
||||||
52
AGENTS.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Agent Instructions for Meat Farmer Monorepo
|
||||||
|
|
||||||
|
## Important instructions
|
||||||
|
- Don't try to build the code or run or compile it. Just make changes and leave the rest for the user.
|
||||||
|
- Don't run any drizzle migrations. User will handle it.
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### TypeScript & React Native
|
||||||
|
- **Strict TypeScript**: `strict: true` enabled in tsconfig
|
||||||
|
- **Path aliases**: Use `@/*` for local imports, `common-ui` for shared UI package
|
||||||
|
- **Component naming**: PascalCase (e.g., `MyButton`, `ImageCarousel`)
|
||||||
|
- **Function naming**: camelCase (e.g., `handleSubmit`, `getCurrentUserId`)
|
||||||
|
- **Interface naming**: PascalCase with `Props` suffix (e.g., `ButtonProps`)
|
||||||
|
|
||||||
|
don't import Text,TextInput,TouchableOpacity and other such primitive components directly from
|
||||||
|
react-native. They are available in the common-ui as MyText, MyTextInput, MyTouchableOpacity etc.
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
- React imports first
|
||||||
|
- Third-party libraries second
|
||||||
|
- Local imports last
|
||||||
|
- Use absolute imports with path aliases when possible
|
||||||
|
- imports from /packages/ui are aliased as `common-ui` in apps/user-ui and apps/admin-ui
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- API errors handled via axios interceptors
|
||||||
|
- Use `DeviceEventEmitter` for cross-component communication
|
||||||
|
- Throw custom errors with descriptive messages
|
||||||
|
- Avoid try-catch blocks unless necessary
|
||||||
|
|
||||||
|
### Formatting & Linting
|
||||||
|
- ESLint with Expo config
|
||||||
|
- No Prettier configured - follow consistent indentation (2 spaces)
|
||||||
|
- No semicolons at end of statements
|
||||||
|
- Single quotes for strings
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- No test framework currently configured
|
||||||
|
- When adding tests, use Jest + React Native Testing Library
|
||||||
|
- Test files: `*.test.tsx` or `*.spec.tsx`
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- Monorepo with Turbo
|
||||||
|
- Shared UI components in `packages/ui`
|
||||||
|
- Apps: `user-ui`, `admin-ui`, `inspiration-ui`, `inspiration-backend`
|
||||||
|
- Database: Drizzle ORM with PostgreSQL
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
- **Do not run build, compile, or migration commands** - These should be handled manually by developers
|
||||||
|
- Avoid running `npm run build`, `tsc`, `drizzle-kit generate`, or similar compilation/migration commands
|
||||||
|
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user
|
||||||
45
Dockerfile
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Optimized Dockerfile for backend and fallback-ui services (project root)
|
||||||
|
|
||||||
|
# 1. ---- Base Node image
|
||||||
|
FROM node:20-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 2. ---- Pruner ----
|
||||||
|
FROM base AS pruner
|
||||||
|
WORKDIR /app
|
||||||
|
# Copy config files first for better caching
|
||||||
|
COPY package.json package-lock.json turbo.json ./
|
||||||
|
COPY apps/backend/package.json ./apps/backend/
|
||||||
|
COPY apps/fallback-ui/package.json ./apps/fallback-ui/
|
||||||
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
|
RUN npm install -g turbo
|
||||||
|
COPY . .
|
||||||
|
RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker
|
||||||
|
|
||||||
|
# 3. ---- Builder ----
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
# Copy package files first to cache npm install
|
||||||
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||||
|
COPY --from=pruner /app/turbo.json .
|
||||||
|
RUN npm ci
|
||||||
|
# Copy source code after dependencies are installed
|
||||||
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
RUN npx turbo run build --filter=fallback-ui... --filter=backend...
|
||||||
|
|
||||||
|
# 4. ---- Runner ----
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Copy package files and install production deps
|
||||||
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||||
|
RUN npm ci --production --omit=dev
|
||||||
|
# Copy built applications
|
||||||
|
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
||||||
|
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
|
||||||
|
EXPOSE 4000
|
||||||
|
RUN npm i -g bun
|
||||||
|
CMD ["bun", "apps/backend/dist/index.js"]
|
||||||
|
# CMD ["node", "apps/backend/dist/index.js"]
|
||||||
34
Dockerfile.txt
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Dockerfile for backend service (project root)
|
||||||
|
|
||||||
|
# 1. ---- Base Node image
|
||||||
|
FROM node:20-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 2. ---- Pruner ----
|
||||||
|
FROM base AS pruner
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g turbo
|
||||||
|
COPY . .
|
||||||
|
RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker
|
||||||
|
|
||||||
|
# 3. ---- Builder ----
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||||
|
COPY --from=pruner /app/turbo.json .
|
||||||
|
RUN npm install
|
||||||
|
RUN npx turbo run build --filter=fallback-ui... --filter=backend...
|
||||||
|
|
||||||
|
# 4. ---- Runner ----
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||||
|
RUN npm ci --production
|
||||||
|
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
||||||
|
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["node", "apps/backend/dist/index.js"]
|
||||||
9
LICENSE
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 shafi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
16
README.md
Executable file
|
|
@ -0,0 +1,16 @@
|
||||||
|
.env file structure
|
||||||
|
DATABASE_URL=****
|
||||||
|
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
||||||
|
PHONE_PE_CLIENT_ID=****
|
||||||
|
PHONE_PE_CLIENT_VERSION=1
|
||||||
|
PHONE_PE_CLIENT_SECRET=****
|
||||||
|
PHONE_PE_MERCHANT_ID=****
|
||||||
|
S3_REGION=****
|
||||||
|
S3_ACCESS_KEY_ID=****
|
||||||
|
S3_SECRET_ACCESS_KEY=****
|
||||||
|
S3_URL=****
|
||||||
|
S3_BUCKET_NAME=****
|
||||||
|
EXPO_ACCESS_TOKEN=****
|
||||||
|
|
||||||
|
|
||||||
|
also add google-services.json and firebase sdk files to ui folder to run notif functionality
|
||||||
3
app.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"expo": {}
|
||||||
|
}
|
||||||
8
apps/admin-ui/.expo/README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
> Why do I have a folder named ".expo" in my project?
|
||||||
|
The ".expo" folder is created when an Expo project is started using "expo start" command.
|
||||||
|
> What do the files contain?
|
||||||
|
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
|
||||||
|
- "settings.json": contains the server configuration that is used to serve the application manifest.
|
||||||
|
> Should I commit the ".expo" folder?
|
||||||
|
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
|
||||||
|
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
|
||||||
1
apps/admin-ui/.expo/cache/eslint/.cache_1wqyakh
vendored
Normal file
3
apps/admin-ui/.expo/devices.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"devices": []
|
||||||
|
}
|
||||||
4
apps/admin-ui/.expo/prebuild/cached-packages.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"dependencies": "091948e86692e0cce7744b6b0543448538c3125a",
|
||||||
|
"devDependencies": "b3b38265f32b99a8299270a292f38ca26288d53d"
|
||||||
|
}
|
||||||
14
apps/admin-ui/.expo/types/router.d.ts
vendored
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 9 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 884 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 527 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 271 KiB |
6
apps/admin-ui/.gitignore
vendored
Executable file
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
50
apps/admin-ui/README.md
Executable file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Welcome to your Expo app 👋
|
||||||
|
|
||||||
|
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
In the output, you'll find options to open the app in a
|
||||||
|
|
||||||
|
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||||
|
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
|
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||||
|
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||||
|
|
||||||
|
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||||
|
|
||||||
|
## Get a fresh project
|
||||||
|
|
||||||
|
When you're ready, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run reset-project
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
To learn more about developing your project with Expo, look at the following resources:
|
||||||
|
|
||||||
|
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||||
|
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||||
|
|
||||||
|
## Join the community
|
||||||
|
|
||||||
|
Join our community of developers creating universal apps.
|
||||||
|
|
||||||
|
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||||
|
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||||
81
apps/admin-ui/app.json
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Symbuyote Admin",
|
||||||
|
"slug": "freshyoadmin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/symbuyoteadmin.png",
|
||||||
|
"scheme": "freshyoadmin",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "in.freshyo.adminui",
|
||||||
|
"infoPlist": {
|
||||||
|
"LSApplicationQueriesSchemes": [
|
||||||
|
"ppemerchantsdkv1",
|
||||||
|
"ppemerchantsdkv2",
|
||||||
|
"ppemerchantsdkv3",
|
||||||
|
"paytmmp",
|
||||||
|
"gpay",
|
||||||
|
"ppemerchantsdkv1",
|
||||||
|
"ppemerchantsdkv2",
|
||||||
|
"ppemerchantsdkv3",
|
||||||
|
"paytmmp",
|
||||||
|
"gpay",
|
||||||
|
"ppemerchantsdkv1",
|
||||||
|
"ppemerchantsdkv2",
|
||||||
|
"ppemerchantsdkv3",
|
||||||
|
"paytmmp",
|
||||||
|
"gpay",
|
||||||
|
"ppemerchantsdkv1",
|
||||||
|
"ppemerchantsdkv2",
|
||||||
|
"ppemerchantsdkv3",
|
||||||
|
"paytmmp",
|
||||||
|
"gpay"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/images/symbuyoteadmin.png",
|
||||||
|
"backgroundColor": "#fff0f6"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"package": "in.freshyo.adminui"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/images/symbuyoteadmin.png",
|
||||||
|
"imageWidth": 200,
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "55e2f200-eb9d-4880-a193-70f59320e054"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/55e2f200-eb9d-4880-a193-70f59320e054"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
232
apps/admin-ui/app/(drawer)/_layout.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import { Drawer } from "expo-router/drawer";
|
||||||
|
import { DrawerContentScrollView, DrawerItem } from "@react-navigation/drawer";
|
||||||
|
import { useRouter, Redirect } from "expo-router";
|
||||||
|
import { useNavigation, DrawerActions } from "@react-navigation/native";
|
||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
DeviceEventEmitter,
|
||||||
|
View,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { REFRESH_EVENT } from "common-ui/src/lib/const-strs";
|
||||||
|
import { useStaffAuth } from "@/components/context/staff-auth-context";
|
||||||
|
import { tw, MyText, theme } from "common-ui";
|
||||||
|
|
||||||
|
function CustomDrawerContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { logout, staff } = useStaffAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerContentScrollView>
|
||||||
|
{staff && (
|
||||||
|
<View style={tw`p-4 border-b border-gray-200`}>
|
||||||
|
<MaterialIcons name="person" size={40} color="#3B82F6" />
|
||||||
|
<View style={tw`mt-2`}>
|
||||||
|
<MyText style={tw`text-lg font-semibold text-gray-800`}>
|
||||||
|
{staff.name}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>Staff Member</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<DrawerItem
|
||||||
|
label="Dashboard"
|
||||||
|
onPress={() => router.push("/(drawer)/dashboard" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="dashboard" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <DrawerItem
|
||||||
|
label="Add Product"
|
||||||
|
onPress={() => router.push("/(drawer)/add-product" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="add" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<DrawerItem
|
||||||
|
label="Products"
|
||||||
|
onPress={() => router.push("/(drawer)/products" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="inventory" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Prices Overview"
|
||||||
|
onPress={() => router.push("/(drawer)/prices-overview" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="attach-money" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Product Groupings"
|
||||||
|
onPress={() => router.push("/(drawer)/product-groupings" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="group-work" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Dashboard Banners"
|
||||||
|
onPress={() => router.push("/(drawer)/dashboard-banners" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="view-carousel" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Slots"
|
||||||
|
onPress={() => router.push("/(drawer)/slots" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="schedule" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <DrawerItem
|
||||||
|
label="Edit Product"
|
||||||
|
onPress={() => router.push("/(drawer)/edit-product" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="edit" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<DrawerItem
|
||||||
|
label="Complaints"
|
||||||
|
onPress={() => router.push("/(drawer)/complaints" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="report-problem" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Manage Orders"
|
||||||
|
onPress={() => router.push("/(drawer)/manage-orders" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="shopping-bag" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Coupons"
|
||||||
|
onPress={() => router.push("coupons" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="local-offer" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Vendor Snippets"
|
||||||
|
onPress={() => router.push("vendor-snippets" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="code" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Stores"
|
||||||
|
onPress={() => router.push("/(drawer)/stores" as any)}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="store" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DrawerItem
|
||||||
|
label="Logout"
|
||||||
|
onPress={() => logout()}
|
||||||
|
icon={({ color, size }) => (
|
||||||
|
<MaterialIcons name="logout" size={size} color={color} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DrawerContentScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { isLoggedIn, isLoading } = useStaffAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return <Redirect href="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
backBehavior="history"
|
||||||
|
drawerContent={CustomDrawerContent}
|
||||||
|
screenOptions={({ navigation, route }) => ({
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: theme.colors.gray1,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
shadowRadius: 0,
|
||||||
|
shadowOffset: { height: 0, width: 0 },
|
||||||
|
elevation: 0,
|
||||||
|
},
|
||||||
|
headerTitleAlign: "center",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => (navigation as any).openDrawer()}
|
||||||
|
style={{
|
||||||
|
marginLeft: 15,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "#f2f2f2",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="menu" size={24} color="black" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
marginRight: 15,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "#f2f2f2",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
DeviceEventEmitter.emit(REFRESH_EVENT);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="refresh" size={24} color="black" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Drawer.Screen name="dashboard" options={{ title: "Dashboard" }} />
|
||||||
|
<Drawer.Screen name="add-product" options={{ title: "Add Product" }} />
|
||||||
|
<Drawer.Screen name="products" options={{ title: "Products" }} />
|
||||||
|
<Drawer.Screen name="prices-overview" options={{ title: "Prices Overview" }} />
|
||||||
|
<Drawer.Screen name="product-groupings" options={{ title: "Product Groupings" }} />
|
||||||
|
<Drawer.Screen name="create-product-group" options={{ title: "Create Product Group" }} />
|
||||||
|
<Drawer.Screen name="edit-product-group/[id]" options={{ title: "Edit Product Group" }} />
|
||||||
|
|
||||||
|
<Drawer.Screen name="edit-product" options={{ title: "Edit Product" }} />
|
||||||
|
<Drawer.Screen
|
||||||
|
name="manage-orders"
|
||||||
|
options={{ title: "Manage Orders" }}
|
||||||
|
/>
|
||||||
|
<Drawer.Screen name="complaints" options={{ title: "Complaints" }} />
|
||||||
|
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
|
||||||
|
<Drawer.Screen name="create-coupon" options={{ title: "Create Coupon" }} />
|
||||||
|
<Drawer.Screen name="edit-coupon/[id]" options={{ title: "Edit Coupon" }} />
|
||||||
|
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
||||||
|
<Drawer.Screen name="add-slot" options={{ title: "Add Slot" }} />
|
||||||
|
<Drawer.Screen name="edit-slot/[id]" options={{ title: "Edit Slot" }} />
|
||||||
|
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||||
|
<Drawer.Screen name="delivery-sequences" options={{ title: "Delivery Sequences", headerShown: false }} />
|
||||||
|
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||||
|
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
||||||
|
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||||
|
<Drawer.Screen name="add-tag" options={{ title: "Add Tag" }} />
|
||||||
|
<Drawer.Screen name="edit-tag" options={{ title: "Edit Tag" }} />
|
||||||
|
<Drawer.Screen name="order-details/[id]" options={{ title: "Order Details" }} />
|
||||||
|
<Drawer.Screen name="orders" options={{ title: "Orders" }} />
|
||||||
|
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-ui/app/(drawer)/add-product/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Add Product",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/admin-ui/app/(drawer)/add-product/index.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { AppContainer } from 'common-ui';
|
||||||
|
import ProductForm from '../../../src/components/ProductForm';
|
||||||
|
import { useCreateProduct, CreateProductPayload } from '../../../src/api-hooks/product.api';
|
||||||
|
|
||||||
|
export default function AddProduct() {
|
||||||
|
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
|
||||||
|
|
||||||
|
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
|
||||||
|
const payload: CreateProductPayload = {
|
||||||
|
name: values.name,
|
||||||
|
shortDescription: values.shortDescription,
|
||||||
|
longDescription: values.longDescription,
|
||||||
|
unitId: parseInt(values.unitId),
|
||||||
|
storeId: parseInt(values.storeId),
|
||||||
|
price: parseFloat(values.price),
|
||||||
|
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||||
|
incrementStep: 1,
|
||||||
|
productQuantity: values.productQuantity || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
formData.append(key, value as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append tag IDs
|
||||||
|
if (values.tagIds && values.tagIds.length > 0) {
|
||||||
|
values.tagIds.forEach((tagId: number) => {
|
||||||
|
formData.append('tagIds', tagId.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append images
|
||||||
|
if (images) {
|
||||||
|
images.forEach((image, index) => {
|
||||||
|
if (image.uri) {
|
||||||
|
formData.append('images', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: `image-${index}.jpg`,
|
||||||
|
// type: 'image/jpeg',
|
||||||
|
type: image.mimeType as any,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createProduct(formData, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Alert.alert('Success', 'Product created successfully!');
|
||||||
|
// Reset form or navigate
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to create product');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
name: '',
|
||||||
|
shortDescription: '',
|
||||||
|
longDescription: '',
|
||||||
|
unitId: 0,
|
||||||
|
price: '',
|
||||||
|
storeId: 1,
|
||||||
|
marketPrice: '',
|
||||||
|
deals: [{ quantity: '', price: '', validTill: new Date() }],
|
||||||
|
tagIds: [],
|
||||||
|
isSuspended: false,
|
||||||
|
isFlashAvailable: false,
|
||||||
|
flashPrice: '',
|
||||||
|
productQuantity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<ProductForm
|
||||||
|
mode="create"
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={isCreating}
|
||||||
|
existingImages={[]}
|
||||||
|
/>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/add-slot/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Add Slot' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/admin-ui/app/(drawer)/add-slot/index.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { AppContainer } from 'common-ui';
|
||||||
|
import SlotForm from '../../../components/SlotForm';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { trpc } from '../../../src/trpc-client';
|
||||||
|
|
||||||
|
export default function AddSlot() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { refetch } = trpc.admin.slots.getAll.useQuery();
|
||||||
|
|
||||||
|
const handleSlotAdded = () => {
|
||||||
|
refetch();
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<SlotForm onSlotAdded={handleSlotAdded} />
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/add-store/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Add Store' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/admin-ui/app/(drawer)/add-store/index.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Alert } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { AppContainer, MyText, tw } from 'common-ui';
|
||||||
|
import StoreForm, { StoreFormData } from '@/components/StoreForm';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
|
export default function AddStore() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const createStoreMutation = trpc.admin.store.createStore.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = (values: StoreFormData) => {
|
||||||
|
createStoreMutation.mutate(values, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Alert.alert('Success', data.message);
|
||||||
|
router.push('/(drawer)/stores' as any); // Navigate back to stores list
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to create store');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-2xl font-bold text-gray-800 mb-6`}>Add New Store</MyText>
|
||||||
|
<StoreForm
|
||||||
|
mode="create"
|
||||||
|
initialValues={{ name: '', description: '', owner: 0, products: [] }}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={createStoreMutation.isPending}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-ui/app/(drawer)/add-tag/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Add Tag",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
apps/admin-ui/app/(drawer)/add-tag/index.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Alert } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { AppContainer, MyText, tw } from 'common-ui';
|
||||||
|
import TagForm from '@/src/components/TagForm';
|
||||||
|
import { useCreateTag } from '@/src/api-hooks/tag.api';
|
||||||
|
|
||||||
|
interface TagFormData {
|
||||||
|
tagName: string;
|
||||||
|
tagDescription: string;
|
||||||
|
isDashboardTag: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddTag() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { mutate: createTag, isPending: isCreating } = useCreateTag();
|
||||||
|
|
||||||
|
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Add text fields
|
||||||
|
formData.append('tagName', values.tagName);
|
||||||
|
if (values.tagDescription) {
|
||||||
|
formData.append('tagDescription', values.tagDescription);
|
||||||
|
}
|
||||||
|
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||||
|
|
||||||
|
// Add image if uploaded
|
||||||
|
if (image?.uri) {
|
||||||
|
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||||
|
const match = /\.(\w+)$/.exec(filename);
|
||||||
|
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||||
|
|
||||||
|
formData.append('image', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: filename,
|
||||||
|
type,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
createTag(formData, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Alert.alert('Success', 'Tag created successfully', [
|
||||||
|
{
|
||||||
|
text: 'OK',
|
||||||
|
onPress: () => router.back(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const errorMessage = error.message || 'Failed to create tag';
|
||||||
|
Alert.alert('Error', errorMessage);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialValues: TagFormData = {
|
||||||
|
tagName: '',
|
||||||
|
tagDescription: '',
|
||||||
|
isDashboardTag: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<TagForm
|
||||||
|
mode="create"
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={isCreating}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/admin-ui/app/(drawer)/address-management/index.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
|
||||||
|
import { BottomDialog , tw } from 'common-ui'
|
||||||
|
import { trpc } from '@/src/trpc-client'
|
||||||
|
import AddressZoneForm from '@/components/AddressZoneForm'
|
||||||
|
import AddressPlaceForm from '@/components/AddressPlaceForm'
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||||
|
|
||||||
|
const AddressManagement: React.FC = () => {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [dialogType, setDialogType] = useState<'zone' | 'place' | null>(null)
|
||||||
|
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const { data: zones, refetch: refetchZones } = trpc.admin.address.getZones.useQuery()
|
||||||
|
const { data: areas, refetch: refetchAreas } = trpc.admin.address.getAreas.useQuery()
|
||||||
|
|
||||||
|
const createZone = trpc.admin.address.createZone.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchZones()
|
||||||
|
setDialogOpen(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createArea = trpc.admin.address.createArea.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchAreas()
|
||||||
|
setDialogOpen(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAddZone = () => {
|
||||||
|
setDialogType('zone')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPlace = () => {
|
||||||
|
setDialogType('place')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleZone = (zoneId: number) => {
|
||||||
|
setExpandedZones(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(zoneId)) {
|
||||||
|
newSet.delete(zoneId)
|
||||||
|
} else {
|
||||||
|
newSet.add(zoneId)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedAreas = areas?.reduce((acc, area) => {
|
||||||
|
if (area.zoneId) {
|
||||||
|
if (!acc[area.zoneId]) acc[area.zoneId] = []
|
||||||
|
acc[area.zoneId].push(area)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {} as Record<number, typeof areas[0][]>) || {}
|
||||||
|
|
||||||
|
const unzonedAreas = areas?.filter(a => !a.zoneId) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-white`}>
|
||||||
|
<View style={tw`flex-row justify-between p-4`}>
|
||||||
|
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={handleAddZone}>
|
||||||
|
<Text style={tw`text-white`}>Add Zone</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={tw`bg-green1 px-4 py-2 rounded`} onPress={handleAddPlace}>
|
||||||
|
<Text style={tw`text-white`}>Add Place</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={tw`flex-1 p-4`}>
|
||||||
|
{zones?.map(zone => (
|
||||||
|
<View key={zone.id} style={tw`mb-4 border border-gray-300 rounded`}>
|
||||||
|
<TouchableOpacity style={tw`flex-row items-center p-3 bg-gray-100`} onPress={() => toggleZone(zone.id)}>
|
||||||
|
<Text style={tw`flex-1 text-lg font-semibold`}>{zone.zoneName}</Text>
|
||||||
|
<MaterialIcons name={expandedZones.has(zone.id) ? 'expand-less' : 'expand-more'} size={24} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
{expandedZones.has(zone.id) && (
|
||||||
|
<View style={tw`p-3`}>
|
||||||
|
{groupedAreas[zone.id]?.map(area => (
|
||||||
|
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||||
|
)) || <Text style={tw`text-gray-500`}>No places in this zone</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={tw`mt-6`}>
|
||||||
|
<Text style={tw`text-xl font-bold mb-2`}>Unzoned Places</Text>
|
||||||
|
{unzonedAreas.map(area => (
|
||||||
|
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||||
|
))}
|
||||||
|
{unzonedAreas.length === 0 && <Text style={tw`text-gray-500`}>No unzoned places</Text>}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||||
|
{dialogType === 'zone' && <AddressZoneForm onSubmit={createZone.mutate} onClose={() => setDialogOpen(false)} />}
|
||||||
|
{dialogType === 'place' && <AddressPlaceForm onSubmit={createArea.mutate} onClose={() => setDialogOpen(false)} />}
|
||||||
|
</BottomDialog>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddressManagement
|
||||||
9
apps/admin-ui/app/(drawer)/complaints/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Complaints' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
apps/admin-ui/app/(drawer)/complaints/index.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, Text, TouchableOpacity, Alert } from "react-native";
|
||||||
|
import { tw, ConfirmationDialog, MyText, MyFlatList, useMarkDataFetchers, usePagination, ImageViewerURI } from "common-ui";
|
||||||
|
import { trpc } from "@/src/trpc-client";
|
||||||
|
|
||||||
|
export default function Complaints() {
|
||||||
|
const { currentPage, pageSize, PaginationComponent } = usePagination(5); // 5 complaints per page for testing
|
||||||
|
const { data, isLoading, error, refetch } = trpc.admin.complaint.getAll.useQuery({
|
||||||
|
page: currentPage,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
|
const resolveComplaint = trpc.admin.complaint.resolve.useMutation();
|
||||||
|
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const complaints = data?.complaints || [];
|
||||||
|
const totalCount = data?.totalCount || 0;
|
||||||
|
|
||||||
|
const handleMarkResolved = (id: number) => {
|
||||||
|
setSelectedComplaintId(id);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmResolve = (response?: string) => {
|
||||||
|
if (!selectedComplaintId) return;
|
||||||
|
|
||||||
|
resolveComplaint.mutate(
|
||||||
|
{ id: String(selectedComplaintId), response },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert("Success", "Complaint marked as resolved");
|
||||||
|
refetch();
|
||||||
|
setDialogOpen(false);
|
||||||
|
setSelectedComplaintId(null);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
error.message || "Failed to resolve complaint"
|
||||||
|
);
|
||||||
|
setDialogOpen(false);
|
||||||
|
setSelectedComplaintId(null);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-gray-600`}>Loading complaints...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-red-600`}>Error loading complaints</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyFlatList
|
||||||
|
style={tw`flex-1 bg-white`}
|
||||||
|
contentContainerStyle={tw`px-4 pb-6`}
|
||||||
|
data={complaints}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg`}>
|
||||||
|
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Complaint #{item.id}</MyText>
|
||||||
|
<MyText style={tw`text-base mb-2 text-gray-700`}>{item.text}</MyText>
|
||||||
|
|
||||||
|
{item.images && item.images.length > 0 && (
|
||||||
|
<View style={tw`mt-3 mb-3`}>
|
||||||
|
<MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>Attached Images:</MyText>
|
||||||
|
<View style={tw`flex-row flex-wrap gap-2`}>
|
||||||
|
{item.images.map((imageUri: string, index: number) => (
|
||||||
|
<ImageViewerURI
|
||||||
|
key={index}
|
||||||
|
uri={imageUri}
|
||||||
|
style={tw`w-16 h-16 rounded-lg border border-gray-200`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert("User Page", "User page coming soon")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-sm text-blue-600 underline`}>
|
||||||
|
{item.userName}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<MyText style={tw`text-sm text-gray-600 mx-2`}>|</MyText>
|
||||||
|
{item.orderId && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert("Order Page", "Order page coming soon")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-sm text-blue-600 underline`}>
|
||||||
|
Order #{item.orderId}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-sm ${
|
||||||
|
item.status === "resolved" ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Status: {item.status}
|
||||||
|
</MyText>
|
||||||
|
{item.status === "pending" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleMarkResolved(item.id)}
|
||||||
|
style={tw`mt-2 bg-blue-500 p-3 rounded-lg shadow-md`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white text-center font-semibold`}>Mark as Resolved</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-10`}>
|
||||||
|
<MyText style={tw`text-gray-500 text-center`}>No complaints found</MyText>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PaginationComponent totalCount={totalCount} />
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
positiveAction={handleConfirmResolve}
|
||||||
|
commentNeeded={true}
|
||||||
|
negativeAction={() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setSelectedComplaintId(null);
|
||||||
|
}}
|
||||||
|
title="Mark as Resolved"
|
||||||
|
message="Add admin notes for this resolution:"
|
||||||
|
confirmText="Resolve"
|
||||||
|
cancelText="Cancel"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-ui/app/(drawer)/coupons/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Coupons",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
515
apps/admin-ui/app/(drawer)/coupons/index.tsx
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { View, TouchableOpacity, Alert, RefreshControl, Dimensions } from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { tw, MyButton, MyText, SearchBar, MyFlatList, useMarkDataFetchers, MyTouchableOpacity, BottomDropdown } from 'common-ui';
|
||||||
|
import useManualRefresh from 'common-ui/hooks/useManualRefresh';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { TabView, SceneMap, TabBar } from 'react-native-tab-view';
|
||||||
|
|
||||||
|
const CouponItem = ({ item, onDelete }: { item: any; onDelete: (id: number) => void }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const getCouponStatus = (coupon: any) => {
|
||||||
|
if (coupon.isInvalidated) return 'inactive';
|
||||||
|
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired';
|
||||||
|
return 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = getCouponStatus(item);
|
||||||
|
|
||||||
|
const getBorderColor = () => {
|
||||||
|
if (status === 'active') return 'border-green-500';
|
||||||
|
if (status === 'expired') return 'border-yellow-500';
|
||||||
|
return 'border-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBgColor = () => {
|
||||||
|
if (status === 'active') return 'bg-green-100';
|
||||||
|
if (status === 'expired') return 'bg-yellow-100';
|
||||||
|
return 'bg-red-100';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = () => {
|
||||||
|
if (status === 'active') return 'text-green-600';
|
||||||
|
if (status === 'expired') return 'text-yellow-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconColor = () => {
|
||||||
|
if (status === 'active') return '#10b981';
|
||||||
|
if (status === 'expired') return '#f59e0b';
|
||||||
|
return '#ef4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg border-l-4 ${getBorderColor()}`}>
|
||||||
|
<View style={tw`flex-row items-center mb-3`}>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full ${getBgColor()} items-center justify-center mr-3`}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={item.discountPercent ? "percent" : "currency-inr"}
|
||||||
|
size={20}
|
||||||
|
color={getIconColor()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800`}>{item.couponCode}</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-500`}>ID: {item.id}</MyText>
|
||||||
|
</View>
|
||||||
|
<View style={tw`px-2 py-1 rounded-full ${getBgColor()}`}>
|
||||||
|
<MyText style={tw`text-xs font-semibold ${getTextColor()}`}>
|
||||||
|
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Inactive'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`bg-gray-50 p-3 rounded-lg mb-3`}>
|
||||||
|
<MyText style={tw`text-base font-semibold mb-1 text-gray-800`}>
|
||||||
|
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `₹${item.flatDiscount} off` : 'N/A'}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row justify-between`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>Min Order: {item.minOrder ? `₹${item.minOrder}` : 'None'}</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>Max: {item.maxValue ? `₹${item.maxValue}` : 'None'}</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-gray-600 mt-1`}>
|
||||||
|
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`mb-3`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-700 mb-1`}>
|
||||||
|
<MaterialCommunityIcons name="account-group" size={14} color="#6b7280" /> Target: {item.isApplyForAll ? 'All Users' : item.applicableUsers?.length > 0 ? `${item.applicableUsers.length} Users` : 'All Users'}
|
||||||
|
</MyText>
|
||||||
|
{item.applicableProducts && item.applicableProducts.length > 0 && (
|
||||||
|
<MyText style={tw`text-sm text-gray-700`}>
|
||||||
|
<MaterialCommunityIcons name="package-variant" size={14} color="#6b7280" /> Products: {item.applicableProducts.length} selected
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row mt-3 gap-2`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push(`/(drawer)/edit-coupon/${item.id}`)}
|
||||||
|
style={tw`bg-blue-500 p-3 rounded-lg shadow-md flex-1 flex-row items-center justify-center`}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="pencil" size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white text-center font-semibold ml-1`}>Edit</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onDelete(item.id)}
|
||||||
|
style={tw`bg-red-500 p-3 rounded-lg shadow-md flex-1 flex-row items-center justify-center`}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="delete" size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white text-center font-semibold ml-1`}>Delete</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReservedCouponItem = ({ item }: { item: any }) => {
|
||||||
|
const getStatus = () => {
|
||||||
|
if (item.isRedeemed) return 'redeemed';
|
||||||
|
if (item.validTill && new Date(item.validTill) <= new Date()) return 'expired';
|
||||||
|
return 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = getStatus();
|
||||||
|
|
||||||
|
const getBorderColor = () => {
|
||||||
|
if (status === 'active') return 'border-green-500';
|
||||||
|
if (status === 'expired') return 'border-yellow-500';
|
||||||
|
return 'border-blue-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBgColor = () => {
|
||||||
|
if (status === 'active') return 'bg-green-100';
|
||||||
|
if (status === 'expired') return 'bg-yellow-100';
|
||||||
|
return 'bg-blue-100';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = () => {
|
||||||
|
if (status === 'active') return 'text-green-600';
|
||||||
|
if (status === 'expired') return 'text-yellow-600';
|
||||||
|
return 'text-blue-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconColor = () => {
|
||||||
|
if (status === 'active') return '#10b981';
|
||||||
|
if (status === 'expired') return '#f59e0b';
|
||||||
|
return '#3b82f6';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg border-l-4 ${getBorderColor()}`}>
|
||||||
|
<View style={tw`flex-row items-center mb-3`}>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full ${getBgColor()} items-center justify-center mr-3`}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={item.discountPercent ? "percent" : "currency-inr"}
|
||||||
|
size={20}
|
||||||
|
color={getIconColor()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800`}>{item.secretCode}</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-500`}>Coupon: {item.couponCode}</MyText>
|
||||||
|
</View>
|
||||||
|
<View style={tw`px-2 py-1 rounded-full ${getBgColor()}`}>
|
||||||
|
<MyText style={tw`text-xs font-semibold ${getTextColor()}`}>
|
||||||
|
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Redeemed'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`bg-gray-50 p-3 rounded-lg mb-3`}>
|
||||||
|
<MyText style={tw`text-base font-semibold mb-1 text-gray-800`}>
|
||||||
|
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `₹${item.flatDiscount} off` : 'N/A'}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row justify-between`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>Min Order: {item.minOrder ? `₹${item.minOrder}` : 'None'}</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>Max: {item.maxValue ? `₹${item.maxValue}` : 'None'}</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-gray-600 mt-1`}>
|
||||||
|
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.isRedeemed && item.redeemedUser && (
|
||||||
|
<View style={tw`mb-3`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-700`}>
|
||||||
|
<MaterialCommunityIcons name="account-check" size={14} color="#6b7280" /> Redeemed by: {item.redeemedUser.name || 'Unknown'} ({item.redeemedUser.mobile})
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>
|
||||||
|
Redeemed on: {item.redeemedAt ? new Date(item.redeemedAt).toLocaleDateString() : 'N/A'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={tw`flex-row justify-between items-center mt-3`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-700`}>
|
||||||
|
<MaterialCommunityIcons name="account" size={14} color="#6b7280" /> Created by: {item.creator?.name || 'Unknown'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GeneralTab = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isLoading,
|
||||||
|
isFetchingNextPage,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = trpc.admin.coupon.getAll.useInfiniteQuery(
|
||||||
|
{ limit: 20, search: searchQuery },
|
||||||
|
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
|
||||||
|
);
|
||||||
|
|
||||||
|
const coupons = useMemo(() => data?.pages.flatMap(page => page.coupons) || [], [data]);
|
||||||
|
|
||||||
|
const getCouponStatus = (coupon: any) => {
|
||||||
|
if (coupon.isInvalidated) return 'inactive';
|
||||||
|
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired';
|
||||||
|
return 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCoupons = useMemo(() => {
|
||||||
|
let filtered = coupons;
|
||||||
|
if (statusFilters.length > 0) {
|
||||||
|
filtered = filtered.filter(coupon => statusFilters.includes(getCouponStatus(coupon)));
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}, [coupons, statusFilters]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
useManualRefresh(() => refetch());
|
||||||
|
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCoupon = trpc.admin.coupon.delete.useMutation();
|
||||||
|
|
||||||
|
const handleDeleteCoupon = (id: number) => {
|
||||||
|
Alert.alert('Delete Coupon', 'Are you sure you want to delete this coupon?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
deleteCoupon.mutate({ id }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert('Success', 'Coupon deleted successfully');
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to delete coupon');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<MaterialCommunityIcons name="loading" size={32} color="#3b82f6" style={tw`mb-4`} />
|
||||||
|
<MyText style={tw`text-lg font-semibold text-gray-600`}>Loading Coupons...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white p-4`}>
|
||||||
|
<MaterialCommunityIcons name="alert-circle" size={32} color="#ef4444" style={tw`mb-4`} />
|
||||||
|
<MyText style={tw`text-lg font-semibold text-red-600 mb-2 text-center`}>Failed to load coupons</MyText>
|
||||||
|
<MyButton onPress={() => refetch()} style={tw`bg-red-500`}>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
||||||
|
</MyButton>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<View style={tw`flex-row items-center px-4 pt-2`}>
|
||||||
|
<View style={tw`flex-1 mr-2`}>
|
||||||
|
<SearchBar
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
placeholder="Search coupons..."
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Filter by Status"
|
||||||
|
value={statusFilters}
|
||||||
|
options={[
|
||||||
|
{ label: 'Active', value: 'active' },
|
||||||
|
{ label: 'Expired', value: 'expired' },
|
||||||
|
{ label: 'Inactive', value: 'inactive' },
|
||||||
|
]}
|
||||||
|
onValueChange={(value) => setStatusFilters(value as string[])}
|
||||||
|
multiple={true}
|
||||||
|
triggerComponent={({ onPress }) => (
|
||||||
|
<TouchableOpacity onPress={onPress} style={tw`p-2`}>
|
||||||
|
<MaterialCommunityIcons name="filter-variant" size={24} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<MyFlatList
|
||||||
|
data={filteredCoupons}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={({ item }) => <CouponItem item={item} onDelete={handleDeleteCoupon} />}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={tw`px-4 pb-4`}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||||
|
<MaterialCommunityIcons name="ticket-percent-outline" size={40} color="#9ca3af" style={tw`mb-4`} />
|
||||||
|
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Coupons Found</MyText>
|
||||||
|
{searchQuery ? (
|
||||||
|
<MyButton onPress={() => setSearchQuery('')} style={tw`bg-gray-500 mt-4`}>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Clear Search</MyText>
|
||||||
|
</MyButton>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReservedTab = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
} = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
|
||||||
|
{ limit: 20, search: searchQuery },
|
||||||
|
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
|
||||||
|
);
|
||||||
|
|
||||||
|
const coupons = useMemo(() => data?.pages.flatMap((page) => page.coupons) || [], [data]);
|
||||||
|
|
||||||
|
const getStatus = (coupon: any) => {
|
||||||
|
if (coupon.isRedeemed) return 'redeemed';
|
||||||
|
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired';
|
||||||
|
return 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCoupons = useMemo(() => {
|
||||||
|
let filtered = coupons;
|
||||||
|
if (statusFilters.length > 0) {
|
||||||
|
filtered = filtered.filter(coupon => statusFilters.includes(getStatus(coupon)));
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}, [coupons, statusFilters]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
useManualRefresh(() => refetch());
|
||||||
|
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<MaterialCommunityIcons name="loading" size={32} color="#3b82f6" style={tw`mb-4`} />
|
||||||
|
<MyText style={tw`text-lg font-semibold text-gray-600`}>Loading Reserved Coupons...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white p-4`}>
|
||||||
|
<MaterialCommunityIcons name="alert-circle" size={32} color="#ef4444" style={tw`mb-4`} />
|
||||||
|
<MyText style={tw`text-lg font-semibold text-red-600 mb-2 text-center`}>Failed to load reserved coupons</MyText>
|
||||||
|
<MyButton onPress={() => refetch()} style={tw`bg-red-500`}>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
||||||
|
</MyButton>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<View style={tw`flex-row items-center px-4 py-2`}>
|
||||||
|
<View style={tw`flex-1 mr-2`}>
|
||||||
|
<SearchBar
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
placeholder="Search reserved coupons..."
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Filter by Status"
|
||||||
|
value={statusFilters}
|
||||||
|
options={[
|
||||||
|
{ label: 'Active', value: 'active' },
|
||||||
|
{ label: 'Expired', value: 'expired' },
|
||||||
|
{ label: 'Redeemed', value: 'redeemed' },
|
||||||
|
]}
|
||||||
|
onValueChange={(value) => setStatusFilters(value as string[])}
|
||||||
|
multiple={true}
|
||||||
|
triggerComponent={({ onPress }) => (
|
||||||
|
<TouchableOpacity onPress={onPress} style={tw`p-2`}>
|
||||||
|
<MaterialCommunityIcons name="filter-variant" size={24} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<MyFlatList
|
||||||
|
data={filteredCoupons}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={({ item }) => <ReservedCouponItem item={item} />}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={tw`px-4 pb-4`}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||||
|
<MaterialCommunityIcons name="ticket-percent-outline" size={40} color="#9ca3af" style={tw`mb-4`} />
|
||||||
|
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Reserved Coupons Found</MyText>
|
||||||
|
{searchQuery ? (
|
||||||
|
<MyButton onPress={() => setSearchQuery('')} style={tw`bg-gray-500 mt-4`}>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Clear Search</MyText>
|
||||||
|
</MyButton>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Coupons() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const routes = [
|
||||||
|
{ key: 'general', title: 'General' },
|
||||||
|
{ key: 'reserved', title: 'Reserved' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-white`}>
|
||||||
|
<TabView
|
||||||
|
navigationState={{ index, routes }}
|
||||||
|
renderScene={SceneMap({
|
||||||
|
general: GeneralTab,
|
||||||
|
reserved: ReservedTab,
|
||||||
|
})}
|
||||||
|
onIndexChange={setIndex}
|
||||||
|
initialLayout={{ width: Dimensions.get('window').width }}
|
||||||
|
lazy
|
||||||
|
renderTabBar={props => (
|
||||||
|
<TabBar
|
||||||
|
{...props}
|
||||||
|
style={{ backgroundColor: '#fff' }}
|
||||||
|
indicatorStyle={{ backgroundColor: '#F83758' }}
|
||||||
|
activeColor='#F83758'
|
||||||
|
inactiveColor='#6b7280'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={() => router.push('/(drawer)/create-coupon')}
|
||||||
|
activeOpacity={0.95}
|
||||||
|
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F83758', '#E91E63']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg`}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="plus" size={32} color="white" />
|
||||||
|
</LinearGradient>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
apps/admin-ui/app/(drawer)/coupons/reserved-coupons/index.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { View, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { tw, MyButton, MyText, SearchBar, MyFlatList, useMarkDataFetchers, MyTouchableOpacity, BottomDropdown } from 'common-ui';
|
||||||
|
import useManualRefresh from 'common-ui/hooks/useManualRefresh';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const ReservedCouponItem = ({ item }: { item: any }) => {
|
||||||
|
const getStatus = () => {
|
||||||
|
if (item.isRedeemed) return 'redeemed';
|
||||||
|
if (item.validTill && new Date(item.validTill) <= new Date()) return 'expired';
|
||||||
|
return 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = getStatus();
|
||||||
|
|
||||||
|
const getBorderColor = () => {
|
||||||
|
if (status === 'active') return 'border-green-500';
|
||||||
|
if (status === 'expired') return 'border-yellow-500';
|
||||||
|
return 'border-blue-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBgColor = () => {
|
||||||
|
if (status === 'active') return 'bg-green-100';
|
||||||
|
if (status === 'expired') return 'bg-yellow-100';
|
||||||
|
return 'bg-blue-100';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = () => {
|
||||||
|
if (status === 'active') return 'text-green-600';
|
||||||
|
if (status === 'expired') return 'text-yellow-600';
|
||||||
|
return 'text-blue-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconColor = () => {
|
||||||
|
if (status === 'active') return '#10b981';
|
||||||
|
if (status === 'expired') return '#f59e0b';
|
||||||
|
return '#3b82f6';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg border-l-4 ${getBorderColor()}`}>
|
||||||
|
<View style={tw`flex-row items-center mb-3`}>
|
||||||
|
<View style={tw`w-10 h-10 rounded-full ${getBgColor()} items-center justify-center mr-3`}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={item.discountPercent ? "percent" : "currency-inr"}
|
||||||
|
size={20}
|
||||||
|
color={getIconColor()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800`}>{item.secretCode}</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-500`}>Coupon: {item.couponCode}</MyText>
|
||||||
|
</View>
|
||||||
|
<View style={tw`px-2 py-1 rounded-full ${getBgColor()}`}>
|
||||||
|
<MyText style={tw`text-xs font-semibold ${getTextColor()}`}>
|
||||||
|
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Redeemed'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`bg-gray-50 p-3 rounded-lg mb-3`}>
|
||||||
|
<MyText style={tw`text-base font-semibold mb-1 text-gray-800`}>
|
||||||
|
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `₹${item.flatDiscount} off` : 'N/A'}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row justify-between`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>Min Order: {item.minOrder ? `₹${item.minOrder}` : 'None'}</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>Max: {item.maxValue ? `₹${item.maxValue}` : 'None'}</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-gray-600 mt-1`}>
|
||||||
|
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.isRedeemed && item.redeemedUser && (
|
||||||
|
<View style={tw`mb-3`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-700`}>
|
||||||
|
<MaterialCommunityIcons name="account-check" size={14} color="#6b7280" /> Redeemed by: {item.redeemedUser.name || 'Unknown'} ({item.redeemedUser.mobile})
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>
|
||||||
|
Redeemed on: {item.redeemedAt ? new Date(item.redeemedAt).toLocaleDateString() : 'N/A'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={tw`flex-row justify-between items-center mt-3`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-700`}>
|
||||||
|
<MaterialCommunityIcons name="account" size={14} color="#6b7280" /> Created by: {item.creator?.name || 'Unknown'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReservedCoupons() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isLoading,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
|
||||||
|
{ limit: 50 },
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const coupons = data?.pages.flatMap(page => page.coupons) || [];
|
||||||
|
|
||||||
|
const getStatus = (coupon: any) => {
|
||||||
|
if (coupon.isRedeemed) return 'redeemed';
|
||||||
|
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired';
|
||||||
|
return 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCoupons = useMemo(() => {
|
||||||
|
let filtered = coupons.filter(coupon =>
|
||||||
|
coupon.secretCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
coupon.couponCode.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
if (statusFilters.length > 0) {
|
||||||
|
filtered = filtered.filter(coupon => statusFilters.includes(getStatus(coupon)));
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}, [coupons, searchQuery, statusFilters]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
useManualRefresh(() => refetch());
|
||||||
|
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<View style={tw`w-16 h-16 bg-blue-100 rounded-full items-center justify-center mb-4`}>
|
||||||
|
<MaterialCommunityIcons name="loading" size={32} color="#3b82f6" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-lg font-semibold text-gray-600`}>Loading Reserved Coupons...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-white`}>
|
||||||
|
<View style={tw`flex-row items-center px-4 py-2`}>
|
||||||
|
<View style={tw`flex-1 mr-2`}>
|
||||||
|
<SearchBar
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
placeholder="Search reserved coupons..."
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Filter by Status"
|
||||||
|
value={statusFilters}
|
||||||
|
options={[
|
||||||
|
{ label: 'Active', value: 'active' },
|
||||||
|
{ label: 'Expired', value: 'expired' },
|
||||||
|
{ label: 'Redeemed', value: 'redeemed' },
|
||||||
|
]}
|
||||||
|
onValueChange={(value) => setStatusFilters(value as string[])}
|
||||||
|
multiple={true}
|
||||||
|
triggerComponent={({ onPress }) => (
|
||||||
|
<TouchableOpacity onPress={onPress} style={tw`p-2`}>
|
||||||
|
<MaterialCommunityIcons name="filter-variant" size={24} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<MyFlatList
|
||||||
|
data={filteredCoupons}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={({ item }) => <ReservedCouponItem item={item} />}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={tw`px-4 pb-4`}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListEmptyComponent={
|
||||||
|
searchQuery ? (
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||||
|
<View style={tw`w-20 h-20 bg-gray-100 rounded-full items-center justify-center mb-4`}>
|
||||||
|
<MaterialCommunityIcons name="magnify" size={40} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Results</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-center mb-4`}>No reserved coupons match “{searchQuery}”</MyText>
|
||||||
|
<MyButton onPress={() => setSearchQuery('')} style={tw`bg-gray-500`}>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Clear Search</MyText>
|
||||||
|
</MyButton>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||||
|
<View style={tw`w-20 h-20 bg-gray-100 rounded-full items-center justify-center mb-4`}>
|
||||||
|
<MaterialCommunityIcons name="ticket-percent-outline" size={40} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Reserved Coupons Yet</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-center mb-4`}>Create your first reserved coupon to start offering secret discounts</MyText>
|
||||||
|
<MyButton onPress={() => router.push('/(drawer)/create-coupon')} style={tw`bg-blue-500`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MaterialCommunityIcons name="plus" size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>Create Reserved Coupon</MyText>
|
||||||
|
</View>
|
||||||
|
</MyButton>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* FAB for Add New Reserved Coupon */}
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={() => router.push('/(drawer)/create-coupon')}
|
||||||
|
activeOpacity={0.95}
|
||||||
|
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F83758', '#E91E63']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-pink300`}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="plus" size={32} color="white" />
|
||||||
|
</LinearGradient>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/create-coupon/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="index" options={{ title: "Create Coupon", headerShown: false }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/admin-ui/app/(drawer)/create-coupon/index.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Alert } from 'react-native';
|
||||||
|
import { tw, AppContainer } from 'common-ui';
|
||||||
|
import CouponForm from '../../../src/components/CouponForm';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
|
export default function CreateCoupon() {
|
||||||
|
const router = useRouter();
|
||||||
|
const createCoupon = trpc.admin.coupon.create.useMutation();
|
||||||
|
const createReservedCoupon = trpc.admin.coupon.createReservedCoupon.useMutation();
|
||||||
|
|
||||||
|
const handleCreateCoupon = (values: any) => {
|
||||||
|
console.log('Form values:', values); // Debug log
|
||||||
|
const { isReservedCoupon, ...rest } = values;
|
||||||
|
// Transform targetUsers array to targetUser for backend compatibility
|
||||||
|
const payload = {
|
||||||
|
...rest,
|
||||||
|
targetUser: rest.targetUsers?.[0] || undefined,
|
||||||
|
};
|
||||||
|
delete payload.targetUsers;
|
||||||
|
console.log('Payload:', payload); // Debug log
|
||||||
|
|
||||||
|
const mutation = isReservedCoupon ? createReservedCoupon : createCoupon;
|
||||||
|
const isLoading = isReservedCoupon ? createReservedCoupon.isPending : createCoupon.isPending;
|
||||||
|
|
||||||
|
if (isLoading) return; // Prevent double submission
|
||||||
|
|
||||||
|
mutation.mutate(payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [
|
||||||
|
{ text: 'OK', onPress: () => router.back() }
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to create coupon');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<CouponForm
|
||||||
|
onSubmit={handleCreateCoupon}
|
||||||
|
isLoading={createCoupon.isPending || createReservedCoupon.isPending}
|
||||||
|
/>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/admin-ui/app/(drawer)/create-product-group.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { AppContainer } from 'common-ui';
|
||||||
|
import ProductGroupForm from '../../components/ProductGroupForm';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
|
export default function CreateProductGroup() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<ProductGroupForm
|
||||||
|
onClose={() => router.back()}
|
||||||
|
onSuccess={() => router.back()}
|
||||||
|
/>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/admin-ui/app/(drawer)/customize-app/_layout.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Customize App",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="popular-items"
|
||||||
|
options={{
|
||||||
|
title: "Popular Items",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
apps/admin-ui/app/(drawer)/customize-app/index.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, ScrollView, Alert } from 'react-native';
|
||||||
|
import { Formik } from 'formik';
|
||||||
|
import { MyText, MyTextInput, MyTouchableOpacity, tw, AppContainer, Checkbox } from 'common-ui';
|
||||||
|
import { trpc } from '../../../src/trpc-client';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
|
||||||
|
interface ConstantFormData {
|
||||||
|
constants: ConstantItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConstantItem {
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONST_LABELS: Record<string, string> = {
|
||||||
|
minRegularOrderValue: 'Minimum Regular Order Value',
|
||||||
|
freeDeliveryThreshold: 'Free Delivery Threshold',
|
||||||
|
deliveryCharge: 'Delivery Charge',
|
||||||
|
flashFreeDeliveryThreshold: 'Flash Free Delivery Threshold',
|
||||||
|
flashDeliveryCharge: 'Flash Delivery Charge',
|
||||||
|
platformFeePercent: 'Platform Fee Percent',
|
||||||
|
taxRate: 'Tax Rate',
|
||||||
|
minOrderAmountForCoupon: 'Minimum Order Amount for Coupon',
|
||||||
|
maxCouponDiscount: 'Maximum Coupon Discount',
|
||||||
|
flashDeliverySlotId: 'Flash Delivery Slot ID',
|
||||||
|
readableOrderId: 'Readable Order ID',
|
||||||
|
versionNum: 'Version Number',
|
||||||
|
playStoreUrl: 'Play Store URL',
|
||||||
|
appStoreUrl: 'App Store URL',
|
||||||
|
popularItems: 'Popular Items',
|
||||||
|
isFlashDeliveryEnabled: 'Enable Flash Delivery',
|
||||||
|
supportMobile: 'Support Mobile',
|
||||||
|
supportEmail: 'Support Email',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConstantInputProps {
|
||||||
|
constant: ConstantItem;
|
||||||
|
setFieldValue: (field: string, value: any) => void;
|
||||||
|
index: number;
|
||||||
|
router: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue, index, router }) => {
|
||||||
|
const fieldName = `constants.${index}.value`;
|
||||||
|
|
||||||
|
// Special handling for popularItems - show navigation button instead of input
|
||||||
|
if (constant.key === 'popularItems') {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||||
|
{CONST_LABELS[constant.key] || constant.key}
|
||||||
|
</MyText>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={() => router.push('/(drawer)/customize-app/popular-items')}
|
||||||
|
style={tw`bg-blue-50 border-2 border-dashed border-blue-200 p-4 rounded-lg flex-row items-center justify-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="edit" size={20} color="#3b82f6" style={tw`mr-2`} />
|
||||||
|
<MyText style={tw`text-blue-700 font-medium`}>
|
||||||
|
Manage Popular Items ({Array.isArray(constant.value) ? constant.value.length : 0} items)
|
||||||
|
</MyText>
|
||||||
|
<MaterialIcons name="chevron-right" size={20} color="#3b82f6" style={tw`ml-2`} />
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle boolean values - show checkbox
|
||||||
|
if (typeof constant.value === 'boolean') {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||||
|
{CONST_LABELS[constant.key] || constant.key}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={constant.value}
|
||||||
|
onPress={() => setFieldValue(fieldName, !constant.value)}
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`ml-3 text-gray-700`}>
|
||||||
|
{constant.value ? 'Enabled' : 'Disabled'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different value types
|
||||||
|
if (typeof constant.value === 'number') {
|
||||||
|
return (
|
||||||
|
<MyTextInput
|
||||||
|
topLabel={CONST_LABELS[constant.key] || constant.key}
|
||||||
|
value={constant.value.toString()}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
setFieldValue(fieldName, isNaN(numValue) ? 0 : numValue);
|
||||||
|
}}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="Enter number"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(constant.value)) {
|
||||||
|
return (
|
||||||
|
<MyTextInput
|
||||||
|
topLabel={CONST_LABELS[constant.key] || constant.key}
|
||||||
|
value={constant.value.join(', ')}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
const arrayValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||||
|
setFieldValue(fieldName, arrayValue);
|
||||||
|
}}
|
||||||
|
placeholder="Enter comma separated values"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to string
|
||||||
|
return (
|
||||||
|
<MyTextInput
|
||||||
|
topLabel={CONST_LABELS[constant.key] || constant.key}
|
||||||
|
value={String(constant.value)}
|
||||||
|
onChangeText={(value) => setFieldValue(fieldName, value)}
|
||||||
|
placeholder="Enter value"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CustomizeApp() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: constants, isLoading: isLoadingConstants, refetch } = trpc.admin.const.getConstants.useQuery();
|
||||||
|
const { mutate: updateConstants, isPending: isUpdating } = trpc.admin.const.updateConstants.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = (values: ConstantFormData) => {
|
||||||
|
// Filter out constants that haven't changed
|
||||||
|
const changedConstants = values.constants.filter((constant, index) => {
|
||||||
|
const original = constants?.[index];
|
||||||
|
return original && JSON.stringify(constant.value) !== JSON.stringify(original.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changedConstants.length === 0) {
|
||||||
|
Alert.alert('No Changes', 'No constants were modified.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConstants(
|
||||||
|
{ constants: changedConstants },
|
||||||
|
{
|
||||||
|
onSuccess: (response) => {
|
||||||
|
Alert.alert('Success', `Updated ${response.updatedCount} constants successfully.`);
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
Alert.alert('Error', 'Failed to update constants. Please try again.');
|
||||||
|
console.error('Update constants error:', error);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingConstants) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-gray-500`}>Loading constants...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!constants) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-gray-500`}>Failed to load constants</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues: ConstantFormData = {
|
||||||
|
constants: constants.map(c => ({ key: c.key, value: c.value ?? '' } as ConstantItem)),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50 p-4`}>
|
||||||
|
<MyText style={tw`text-xl font-bold mb-4`}>Customize App Constants</MyText>
|
||||||
|
<MyText style={tw`text-gray-600 mb-6`}>
|
||||||
|
Modify application constants. Changes will take effect immediately.
|
||||||
|
</MyText>
|
||||||
|
|
||||||
|
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||||
|
{({ handleSubmit, values, setFieldValue }) => (
|
||||||
|
<View>
|
||||||
|
{values.constants.map((constant, index) => (
|
||||||
|
<View key={constant.key} style={tw`mb-4`}>
|
||||||
|
<ConstantInput constant={constant} setFieldValue={setFieldValue} index={index} router={router} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={() => handleSubmit()}
|
||||||
|
disabled={isUpdating}
|
||||||
|
style={tw`bg-blue-500 p-4 rounded-lg mt-6 ${isUpdating ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white text-center font-semibold`}>
|
||||||
|
{isUpdating ? 'Updating...' : 'Save Changes'}
|
||||||
|
</MyText>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
439
apps/admin-ui/app/(drawer)/customize-app/popular-items.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import DraggableFlatList, {
|
||||||
|
RenderItemParams,
|
||||||
|
ScaleDecorator,
|
||||||
|
} from "react-native-draggable-flatlist";
|
||||||
|
import {
|
||||||
|
AppContainer,
|
||||||
|
MyText,
|
||||||
|
tw,
|
||||||
|
BottomDialog,
|
||||||
|
BottomDropdown,
|
||||||
|
} from "common-ui";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { trpc } from "../../../src/trpc-client";
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
interface PopularProduct {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
shortDescription: string | null;
|
||||||
|
price: string;
|
||||||
|
marketPrice: string | null;
|
||||||
|
unit: string;
|
||||||
|
incrementStep: number;
|
||||||
|
productQuantity: number;
|
||||||
|
storeId: number | null;
|
||||||
|
isOutOfStock: boolean;
|
||||||
|
nextDeliveryDate: string | null;
|
||||||
|
images: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductItemProps {
|
||||||
|
item: PopularProduct;
|
||||||
|
drag: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductItem: React.FC<ProductItemProps> = ({
|
||||||
|
item,
|
||||||
|
drag,
|
||||||
|
isActive,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ScaleDecorator>
|
||||||
|
<TouchableOpacity
|
||||||
|
onLongPress={drag}
|
||||||
|
activeOpacity={1}
|
||||||
|
style={tw`mx-4 my-2`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
tw`bg-white p-4 rounded-xl border`,
|
||||||
|
isActive
|
||||||
|
? tw`shadow-xl border-blue-500 z-50`
|
||||||
|
: tw`shadow-sm border-gray-100`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
<View style={tw`mr-4`}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="drag-indicator"
|
||||||
|
size={24}
|
||||||
|
color={isActive ? "#3b82f6" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Product Image */}
|
||||||
|
{item.images?.[0] ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.images[0] }}
|
||||||
|
style={tw`w-12 h-12 rounded-lg mr-4`}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={tw`w-12 h-12 rounded-lg bg-gray-100 mr-4 items-center justify-center`}>
|
||||||
|
<MaterialIcons name="image" size={20} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`font-semibold text-gray-900 text-base`} numberOfLines={2}>
|
||||||
|
{item.name}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-green-600 font-bold text-sm mt-1`}>
|
||||||
|
₹{item.price}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onDelete(item.id)}
|
||||||
|
style={tw`p-2 ml-2`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="delete" size={20} color="#ef4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScaleDecorator>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CustomizePopularItems() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [popularProducts, setPopularProducts] = useState<PopularProduct[]>([]);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Get current popular items from constants
|
||||||
|
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
|
||||||
|
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||||
|
const updateConstants = trpc.admin.const.updateConstants.useMutation();
|
||||||
|
|
||||||
|
// Initialize popular products from constants
|
||||||
|
useEffect(() => {
|
||||||
|
if (constants && allProducts?.products) {
|
||||||
|
const popularItemsConstant = constants.find(c => c.key === 'popularItems');
|
||||||
|
|
||||||
|
let popularIds: number[] = [];
|
||||||
|
|
||||||
|
if (popularItemsConstant) {
|
||||||
|
const value = popularItemsConstant.value;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// Already an array of IDs
|
||||||
|
popularIds = value.map((id: any) => parseInt(id));
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
// Comma-separated string
|
||||||
|
popularIds = value.split(',').map((id: string) => parseInt(id.trim())).filter(id => !isNaN(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const popularProds: PopularProduct[] = [];
|
||||||
|
|
||||||
|
for (const id of popularIds) {
|
||||||
|
const product = allProducts.products.find(p => p.id === id);
|
||||||
|
if (product) {
|
||||||
|
popularProds.push(product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPopularProducts(popularProds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [constants, allProducts]);
|
||||||
|
|
||||||
|
const handleDragEnd = ({ data }: { data: PopularProduct[] }) => {
|
||||||
|
setPopularProducts(data);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (productId: number) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Remove Product",
|
||||||
|
"Are you sure you want to remove this product from popular items?",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Remove",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
setPopularProducts(prev => prev.filter(p => p.id !== productId));
|
||||||
|
setHasChanges(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddProduct = () => {
|
||||||
|
if (selectedProductId) {
|
||||||
|
const product = allProducts?.products.find(p => p.id === selectedProductId);
|
||||||
|
if (product && !popularProducts.find(p => p.id === product.id)) {
|
||||||
|
setPopularProducts(prev => [...prev, product as PopularProduct]);
|
||||||
|
setHasChanges(true);
|
||||||
|
setSelectedProductId(null);
|
||||||
|
setShowAddDialog(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const popularIds = popularProducts.map(p => p.id);
|
||||||
|
|
||||||
|
updateConstants.mutate(
|
||||||
|
{
|
||||||
|
constants: [{
|
||||||
|
key: 'popularItems',
|
||||||
|
value: popularIds
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setHasChanges(false);
|
||||||
|
Alert.alert('Success', 'Popular items updated successfully!');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
Alert.alert('Error', 'Failed to update popular items. Please try again.');
|
||||||
|
console.error('Update popular items error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableProducts = (allProducts?.products || []).filter(
|
||||||
|
product => !popularProducts.find(p => p.id === product.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const productOptions = availableProducts.map(product => ({
|
||||||
|
label: `${product.name} - ₹${product.price}`,
|
||||||
|
value: product.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Show loading state while data is being fetched
|
||||||
|
if (isLoadingConstants || isLoadingProducts) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={tw`p-2 -ml-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900`}>Popular Items</MyText>
|
||||||
|
|
||||||
|
<View style={tw`w-16`} /> {/* Spacer for centering */}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Loading Content */}
|
||||||
|
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||||
|
<ActivityIndicator size="large" color="#3b82f6" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 text-center`}>
|
||||||
|
{isLoadingConstants ? 'Loading constants...' : 'Loading products...'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error state if queries failed
|
||||||
|
if (constantsError || productsError) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={tw`p-2 -ml-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900`}>Popular Items</MyText>
|
||||||
|
|
||||||
|
<View style={tw`w-16`} /> {/* Spacer for centering */}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Error Content */}
|
||||||
|
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||||
|
<MaterialIcons name="error-outline" size={64} color="#ef4444" />
|
||||||
|
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 mt-2 text-center`}>
|
||||||
|
{constantsError ? 'Failed to load constants' : 'Failed to load products'}
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Go Back</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={tw`p-2 -ml-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900`}>Popular Items</MyText>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!hasChanges || updateConstants.isPending}
|
||||||
|
style={tw`px-4 py-2 rounded-lg ${
|
||||||
|
hasChanges && !updateConstants.isPending
|
||||||
|
? 'bg-blue-600'
|
||||||
|
: 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`${
|
||||||
|
hasChanges && !updateConstants.isPending
|
||||||
|
? 'text-white'
|
||||||
|
: 'text-gray-500'
|
||||||
|
} font-semibold`}>
|
||||||
|
{updateConstants.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{popularProducts.length === 0 ? (
|
||||||
|
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||||
|
<MaterialIcons name="inventory" size={64} color="#e5e7eb" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
|
||||||
|
No popular items configured
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-400 text-center mt-2`}>
|
||||||
|
Add products to display as popular items
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<View style={tw`bg-blue-50 px-4 py-2 mb-2`}>
|
||||||
|
<MyText style={tw`text-blue-700 text-xs text-center`}>
|
||||||
|
Long press an item to drag and reorder
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DraggableFlatList
|
||||||
|
data={popularProducts}
|
||||||
|
renderItem={({ item, drag, isActive }) => (
|
||||||
|
<ProductItem
|
||||||
|
item={item}
|
||||||
|
drag={drag}
|
||||||
|
isActive={isActive}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={tw`pb-8`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB for Add Product */}
|
||||||
|
<View style={tw`absolute bottom-4 right-4`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowAddDialog(true)}
|
||||||
|
style={tw`bg-blue-600 p-4 rounded-full shadow-lg`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Add Product Dialog */}
|
||||||
|
<BottomDialog
|
||||||
|
open={showAddDialog}
|
||||||
|
onClose={() => setShowAddDialog(false)}
|
||||||
|
>
|
||||||
|
<View style={tw`pb-8 pt-2 px-4`}>
|
||||||
|
<View style={tw`items-center mb-6`}>
|
||||||
|
<View style={tw`w-12 h-1.5 bg-gray-200 rounded-full mb-4`} />
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
||||||
|
Add Popular Item
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-500`}>
|
||||||
|
Select a product to add to popular items
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{availableProducts.length === 0 ? (
|
||||||
|
<View style={tw`items-center py-8`}>
|
||||||
|
<MaterialIcons name="inventory" size={48} color="#e5e7eb" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 text-center`}>
|
||||||
|
All products are already in popular items
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Select Product"
|
||||||
|
options={productOptions}
|
||||||
|
value={selectedProductId || ""}
|
||||||
|
onValueChange={(val) => setSelectedProductId(val as number)}
|
||||||
|
placeholder="Choose a product..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={tw`flex-row gap-3 mt-6`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowAddDialog(false)}
|
||||||
|
style={tw`flex-1 bg-gray-100 p-3 rounded-lg`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-gray-700 text-center font-semibold`}>
|
||||||
|
Cancel
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleAddProduct}
|
||||||
|
disabled={!selectedProductId}
|
||||||
|
style={tw`flex-1 ${
|
||||||
|
selectedProductId ? 'bg-blue-600' : 'bg-gray-300'
|
||||||
|
} p-3 rounded-lg`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white text-center font-semibold`}>
|
||||||
|
Add Product
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/admin-ui/app/(drawer)/dashboard-banners/_layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Dashboard Banners' }} />
|
||||||
|
<Stack.Screen name="create-banner" options={{ title: 'Create Banner' }} />
|
||||||
|
<Stack.Screen name="edit-banner" options={{ title: 'Edit Banner' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Create Banner' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||||
|
import { AppContainer, MyText, tw } from 'common-ui';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { FormikHelpers } from 'formik';
|
||||||
|
import BannerForm, { BannerFormData } from '@/components/BannerForm';
|
||||||
|
import { trpc } from '../../../../src/trpc-client';
|
||||||
|
|
||||||
|
export default function CreateBanner() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const initialValues: BannerFormData = {
|
||||||
|
name: '',
|
||||||
|
imageUrl: '',
|
||||||
|
description: '',
|
||||||
|
productIds: [],
|
||||||
|
redirectUrl: '',
|
||||||
|
// serialNum removed - assigned automatically by backend
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBannerMutation = trpc.admin.banner.createBanner.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
|
||||||
|
if (!imageUrl) {
|
||||||
|
Alert.alert('Error', 'Image is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createBannerMutation.mutateAsync({
|
||||||
|
name: values.name,
|
||||||
|
imageUrl,
|
||||||
|
description: values.description || undefined,
|
||||||
|
productIds: values.productIds.length > 0 ? values.productIds : [],
|
||||||
|
redirectUrl: values.redirectUrl || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
Alert.alert('Success', 'Banner created successfully', [
|
||||||
|
{
|
||||||
|
text: 'OK',
|
||||||
|
onPress: () => router.back(),
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
Alert.alert('Error', 'Failed to create banner. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-white`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={tw`flex-row items-center justify-between px-6 py-4 border-b border-gray-200`}>
|
||||||
|
<TouchableOpacity onPress={handleCancel} style={tw`p-2`}>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900`}>Create Banner</MyText>
|
||||||
|
<View style={tw`w-10`} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<BannerForm
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
submitButtonText="Create Banner"
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||||
|
import { AppContainer, MyText, tw } from 'common-ui';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { FormikHelpers } from 'formik';
|
||||||
|
import BannerForm, { BannerFormData } from '@/components/BannerForm';
|
||||||
|
import { trpc } from '../../../../src/trpc-client';
|
||||||
|
|
||||||
|
interface Banner {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
description?: string;
|
||||||
|
productIds?: number[];
|
||||||
|
redirectUrl?: string;
|
||||||
|
serialNum: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditBanner() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const bannerId = id as string;
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
// Load real banner data from API
|
||||||
|
const {data: bannerData } = trpc.admin.banner.getBanner.useQuery({
|
||||||
|
id: parseInt(bannerId)
|
||||||
|
});
|
||||||
|
const [banner, setBanner] = useState<typeof bannerData>(undefined);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadBanner = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (bannerData) {
|
||||||
|
// Handle data format compatibility (productId -> productIds migration)
|
||||||
|
const processedBanner = {
|
||||||
|
...bannerData,
|
||||||
|
productIds: Array.isArray(bannerData.productIds)
|
||||||
|
? bannerData.productIds
|
||||||
|
: (bannerData.productIds ? [bannerData.productIds] : [])
|
||||||
|
};
|
||||||
|
|
||||||
|
setBanner(processedBanner);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', 'Banner not found');
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load banner:', error);
|
||||||
|
Alert.alert('Error', 'Failed to load banner data');
|
||||||
|
router.back();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadBanner();
|
||||||
|
}, [bannerId, bannerData]);
|
||||||
|
|
||||||
|
const initialValues: BannerFormData = banner ? {
|
||||||
|
name: banner.name,
|
||||||
|
imageUrl: banner.imageUrl,
|
||||||
|
description: banner.description || '',
|
||||||
|
productIds: banner.productIds || [],
|
||||||
|
redirectUrl: banner.redirectUrl || '',
|
||||||
|
// serialNum removed - handled automatically by backend
|
||||||
|
} : {
|
||||||
|
name: '',
|
||||||
|
imageUrl: '',
|
||||||
|
description: '',
|
||||||
|
productIds: [],
|
||||||
|
redirectUrl: '',
|
||||||
|
// serialNum removed - handled automatically by backend
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBannerMutation = trpc.admin.banner.updateBanner.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
|
||||||
|
if (!banner) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateBannerMutation.mutateAsync({
|
||||||
|
id: banner.id,
|
||||||
|
name: values.name,
|
||||||
|
imageUrl: imageUrl || banner.imageUrl,
|
||||||
|
description: values.description || undefined,
|
||||||
|
productIds: values.productIds.length > 0 ? values.productIds : [],
|
||||||
|
redirectUrl: values.redirectUrl || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
Alert.alert('Success', 'Banner updated successfully', [
|
||||||
|
{
|
||||||
|
text: 'OK',
|
||||||
|
onPress: () => router.back(),
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to update banner. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<MyText style={tw`text-gray-600`}>Loading banner...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!banner) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<MyText style={tw`text-red-600`}>Banner not found</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-white`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={tw`flex-row items-center justify-between px-6 py-4 border-b border-gray-200`}>
|
||||||
|
<TouchableOpacity onPress={handleCancel} style={tw`p-2`}>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900`}>Edit Banner</MyText>
|
||||||
|
<View style={tw`w-10`} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<BannerForm
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
submitButtonText="Update Banner"
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
existingImageUrl={banner.imageUrl}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="[id]" options={{ title: 'Edit Banner' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
467
apps/admin-ui/app/(drawer)/dashboard-banners/index.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, ScrollView, TouchableOpacity, Alert, Image, RefreshControl } from 'react-native';
|
||||||
|
import { AppContainer, MyText, tw, MyTouchableOpacity } from 'common-ui';
|
||||||
|
import { trpc } from '../../../src/trpc-client';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
|
interface Banner {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
description: string | null;
|
||||||
|
productIds: number[] | null;
|
||||||
|
redirectUrl: string | null;
|
||||||
|
serialNum: number | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardBanners() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Edit mode state
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<number | null>(null);
|
||||||
|
const [slotAssignments, setSlotAssignments] = useState<{[slot: number]: number | null}>({});
|
||||||
|
const [originalAssignments, setOriginalAssignments] = useState<{[slot: number]: number | null}>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Real API calls
|
||||||
|
const { data: bannersData, isLoading, error, refetch } = trpc.admin.banner.getBanners.useQuery();
|
||||||
|
|
||||||
|
const deleteBannerMutation = trpc.admin.banner.deleteBanner.useMutation();
|
||||||
|
const updateBannerMutation = trpc.admin.banner.updateBanner.useMutation();
|
||||||
|
const emptySlotMutation = trpc.admin.banner.updateBanner.useMutation();
|
||||||
|
|
||||||
|
const banners = bannersData?.banners || [];
|
||||||
|
|
||||||
|
// Initialize slot assignments when banners load
|
||||||
|
React.useEffect(() => {
|
||||||
|
const assignments: {[slot: number]: number | null} = {1: null, 2: null, 3: null, 4: null};
|
||||||
|
banners.forEach(banner => {
|
||||||
|
if (banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4) {
|
||||||
|
assignments[banner.serialNum] = banner.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSlotAssignments(assignments);
|
||||||
|
setOriginalAssignments({...assignments});
|
||||||
|
}, [bannersData]);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slot and edit mode handlers
|
||||||
|
const handleSlotClick = (slotNumber: number) => {
|
||||||
|
setSelectedSlot(slotNumber);
|
||||||
|
setEditMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBannerSelect = (bannerId: number) => {
|
||||||
|
if (!editMode || selectedSlot === null) return;
|
||||||
|
|
||||||
|
// Remove banner from any existing slot
|
||||||
|
const newAssignments = {...slotAssignments};
|
||||||
|
Object.keys(newAssignments).forEach(slot => {
|
||||||
|
if (newAssignments[parseInt(slot)] === bannerId) {
|
||||||
|
newAssignments[parseInt(slot)] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign banner to selected slot
|
||||||
|
newAssignments[selectedSlot] = bannerId;
|
||||||
|
setSlotAssignments(newAssignments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// Get banners that need to be updated
|
||||||
|
const bannersToUpdate: {id: number, serialNum: number | null}[] = [];
|
||||||
|
|
||||||
|
// Clear serial numbers for banners no longer in slots
|
||||||
|
banners.forEach(banner => {
|
||||||
|
const currentSlot = banner.serialNum;
|
||||||
|
const assignedSlot = Object.keys(slotAssignments).find(slot =>
|
||||||
|
slotAssignments[parseInt(slot)] === banner.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSlot !== (assignedSlot ? parseInt(assignedSlot) : null)) {
|
||||||
|
bannersToUpdate.push({
|
||||||
|
id: banner.id,
|
||||||
|
serialNum: assignedSlot ? parseInt(assignedSlot) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update banners that gained slots
|
||||||
|
Object.keys(slotAssignments).forEach(slot => {
|
||||||
|
const slotNum = parseInt(slot);
|
||||||
|
const bannerId = slotAssignments[slotNum];
|
||||||
|
if (bannerId) {
|
||||||
|
const banner = banners.find(b => b.id === bannerId);
|
||||||
|
if (banner && banner.serialNum !== slotNum) {
|
||||||
|
bannersToUpdate.push({
|
||||||
|
id: bannerId,
|
||||||
|
serialNum: slotNum
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute updates
|
||||||
|
await Promise.all(
|
||||||
|
bannersToUpdate.map(({id, serialNum}) =>
|
||||||
|
updateBannerMutation.mutateAsync({ id, serialNum })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setOriginalAssignments({...slotAssignments});
|
||||||
|
setEditMode(false);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
await refetch();
|
||||||
|
Alert.alert('Success', 'Slot assignments saved successfully');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to save slot assignments');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setSlotAssignments({...originalAssignments});
|
||||||
|
setEditMode(false);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmptySlot = async () => {
|
||||||
|
if (!selectedSlot || !slotAssignments[selectedSlot]) return;
|
||||||
|
|
||||||
|
const bannerId = slotAssignments[selectedSlot];
|
||||||
|
const banner = banners.find(b => b.id === bannerId);
|
||||||
|
|
||||||
|
if (!banner) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update banner's serialNum to null to empty the slot
|
||||||
|
await emptySlotMutation.mutateAsync({
|
||||||
|
id: banner.id,
|
||||||
|
serialNum: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setSlotAssignments(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selectedSlot]: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update original assignments for cancel functionality
|
||||||
|
setOriginalAssignments(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selectedSlot]: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alert.alert('Success', `Slot ${selectedSlot} has been emptied`);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to empty slot');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get banner name by ID
|
||||||
|
const getBannerNameById = (id: number | null) => {
|
||||||
|
if (!id) return null;
|
||||||
|
const banner = banners.find(b => b.id === id);
|
||||||
|
return banner ? banner.name : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (banner: Banner) => {
|
||||||
|
router.push(`/dashboard-banners/edit-banner/${banner.id}` as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Banner',
|
||||||
|
'Are you sure you want to delete this banner?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await deleteBannerMutation.mutateAsync({ id });
|
||||||
|
refetch();
|
||||||
|
Alert.alert('Success', 'Banner deleted');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to delete banner');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
router.push('/(drawer)/dashboard-banners/create-banner' as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<MyText style={tw`text-gray-600`}>Loading banners...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<MyText style={tw`text-red-600`}>Error loading banners</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
<ScrollView
|
||||||
|
style={tw`flex-1`}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={tw`pb-24`}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
colors={['#3B82F6']} // Blue color to match the theme
|
||||||
|
tintColor="#3B82F6"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{/* <View style={tw`px-4 py-6`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-900 mb-4`}>All Banners</MyText>
|
||||||
|
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={handleCreate}
|
||||||
|
style={tw`bg-blue-600 rounded-lg py-3 px-4 items-center mb-6`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MaterialIcons name="add" size={20} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-2`}>Add New Banner</MyText>
|
||||||
|
</View>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View> */}
|
||||||
|
|
||||||
|
{/* Slots Row */}
|
||||||
|
<View style={tw`px-4 mb-6`}>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-700 mb-3`}>
|
||||||
|
{editMode ? `Select banner for Slot ${selectedSlot}` : 'Banner Slots'}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row justify-between`}>
|
||||||
|
{[1, 2, 3, 4].map(slotNum => {
|
||||||
|
const assignedBannerId = slotAssignments[slotNum];
|
||||||
|
const bannerName = getBannerNameById(assignedBannerId);
|
||||||
|
const isSelected = editMode && selectedSlot === slotNum;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MyTouchableOpacity
|
||||||
|
key={slotNum}
|
||||||
|
onPress={() => handleSlotClick(slotNum)}
|
||||||
|
style={tw`flex-1 mx-1 p-3 rounded-lg border-2 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: assignedBannerId
|
||||||
|
? 'border-green-300 bg-green-50'
|
||||||
|
: 'border-gray-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View style={tw`items-center`}>
|
||||||
|
<MyText style={tw`text-xs text-gray-500 mb-1`}>Slot {slotNum}</MyText>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-xs font-medium text-center ${
|
||||||
|
assignedBannerId ? 'text-gray-900' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{bannerName || 'Empty'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action buttons in edit mode */}
|
||||||
|
{editMode && (
|
||||||
|
<View style={tw`mt-4 gap-3`}>
|
||||||
|
{/* Save/Cancel buttons */}
|
||||||
|
<View style={tw`flex-row gap-3`}>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={saving}
|
||||||
|
style={tw`flex-1 bg-gray-500 rounded-lg py-3 px-4 items-center ${
|
||||||
|
saving ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Cancel</MyText>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
style={tw`flex-1 bg-blue-600 rounded-lg py-3 px-4 items-center ${
|
||||||
|
saving ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</MyText>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Empty Slot button */}
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={handleEmptySlot}
|
||||||
|
disabled={!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending}
|
||||||
|
style={tw`bg-red-500 rounded-lg py-3 px-4 items-center ${
|
||||||
|
(!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending) ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MaterialIcons
|
||||||
|
name={emptySlotMutation.isPending ? "hourglass-empty" : "clear"}
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
<MyText style={tw`text-white font-semibold ml-2`}>
|
||||||
|
{emptySlotMutation.isPending ? 'Emptying...' : 'Empty Slot'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Banners List */}
|
||||||
|
<View style={tw`px-4 pb-8`}>
|
||||||
|
{editMode && (
|
||||||
|
<View style={tw`mb-4 p-3 bg-blue-50 rounded-lg border border-blue-200`}>
|
||||||
|
<MyText style={tw`text-sm text-blue-800 text-center`}>
|
||||||
|
Tap a banner below to assign it to Slot {selectedSlot}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{banners.length === 0 ? (
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-8`}>
|
||||||
|
<View style={tw`w-24 h-24 bg-slate-50 rounded-full items-center justify-center mb-6`}>
|
||||||
|
<MaterialIcons name="image" size={48} color="#94A3B8" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-slate-900 text-xl font-black tracking-tight`}>No Banners Yet</MyText>
|
||||||
|
<MyText style={tw`text-slate-500 text-center mt-2 font-medium px-8`}>
|
||||||
|
Start by creating your first banner using the button above.
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
banners.map((banner) => {
|
||||||
|
const isAssignedToSlot = Object.values(slotAssignments).includes(banner.id);
|
||||||
|
const isAssignedToSelectedSlot = slotAssignments[selectedSlot || 0] === banner.id;
|
||||||
|
const canInteract = editMode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MyTouchableOpacity
|
||||||
|
key={banner.id}
|
||||||
|
onPress={canInteract ? () => handleBannerSelect(banner.id) : undefined}
|
||||||
|
disabled={!canInteract}
|
||||||
|
style={tw`bg-white rounded-xl p-4 mb-3 shadow-sm border ${
|
||||||
|
canInteract && isAssignedToSelectedSlot
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: canInteract && isAssignedToSlot
|
||||||
|
? 'border-green-300 bg-green-50'
|
||||||
|
: canInteract
|
||||||
|
? 'border-gray-200'
|
||||||
|
: 'border-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: banner.imageUrl }}
|
||||||
|
style={tw`w-16 h-16 rounded-lg mr-3`}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<View style={tw`flex-row items-center justify-between mb-1`}>
|
||||||
|
<MyText style={tw`font-semibold text-gray-900`} numberOfLines={1}>
|
||||||
|
{banner.name}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
{canInteract && isAssignedToSelectedSlot && (
|
||||||
|
<View style={tw`mr-2`}>
|
||||||
|
<MaterialIcons name="check-circle" size={16} color="#3B82F6" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{canInteract && isAssignedToSlot && !isAssignedToSelectedSlot && (
|
||||||
|
<View style={tw`mr-2`}>
|
||||||
|
<MaterialIcons name="radio-button-checked" size={16} color="#10B981" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={tw`w-2 h-2 rounded-full mr-2 ${
|
||||||
|
banner.isActive ? 'bg-green-500' : 'bg-gray-400'
|
||||||
|
}`} />
|
||||||
|
{!editMode && (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity onPress={() => handleEdit(banner)} style={tw`p-1 mr-1`}>
|
||||||
|
<MaterialIcons name="edit" size={16} color="#64748B" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => handleDelete(banner.id)} style={tw`p-1`}>
|
||||||
|
<MaterialIcons name="delete" size={16} color="#EF4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{banner.description && (
|
||||||
|
<MyText style={tw`text-sm text-gray-500 mt-1`} numberOfLines={2}>
|
||||||
|
{banner.description}
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
<View style={tw`flex-row items-center justify-between mt-2`}>
|
||||||
|
<MyText style={tw`text-xs text-gray-400`}>
|
||||||
|
Created: {new Date(banner.createdAt).toLocaleDateString()}
|
||||||
|
</MyText>
|
||||||
|
{banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4 && (
|
||||||
|
<MyText style={tw`text-xs text-blue-600 font-medium`}>
|
||||||
|
Slot {banner.serialNum}
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Floating Action Button */}
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={handleCreate}
|
||||||
|
style={tw`absolute bottom-6 right-6 w-14 h-14 rounded-full bg-blue-600 items-center justify-center shadow-lg`}
|
||||||
|
accessibilityLabel="Add new banner"
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={24} color="white" />
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-ui/app/(drawer)/dashboard/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Dashboard",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
apps/admin-ui/app/(drawer)/dashboard/index.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, ScrollView, Pressable } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { MyText, tw } from 'common-ui';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { theme } from 'common-ui/src/theme';
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
description?: string;
|
||||||
|
route: string;
|
||||||
|
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings';
|
||||||
|
iconColor?: string;
|
||||||
|
iconBg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItemComponentProps {
|
||||||
|
item: MenuItem;
|
||||||
|
router: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuItemComponent: React.FC<MenuItemComponentProps> = ({ item, router }) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.route}
|
||||||
|
onPress={() => router.push(item.route as any)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`,
|
||||||
|
pressed && tw`bg-gray-50`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[tw`w-12 h-12 rounded-xl items-center justify-center mr-4`, { backgroundColor: item.iconBg }]}>
|
||||||
|
<MaterialIcons name={item.icon as any} size={24} color={item.iconColor} />
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-gray-900 font-bold text-base mb-0.5`}>{item.title}</MyText>
|
||||||
|
{item.description && (
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>{item.description}</MyText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<MaterialIcons name="chevron-right" size={24} color="#D1D5DB" />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const menuItems: MenuItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Manage Orders',
|
||||||
|
icon: 'shopping-bag',
|
||||||
|
description: 'View and manage customer orders',
|
||||||
|
route: '/(drawer)/orders',
|
||||||
|
category: 'orders',
|
||||||
|
iconColor: '#10B981',
|
||||||
|
iconBg: '#D1FAE5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Delivery Sequences',
|
||||||
|
icon: 'alt-route',
|
||||||
|
description: 'Plan and optimize delivery routes',
|
||||||
|
route: '/(drawer)/delivery-sequences',
|
||||||
|
category: 'orders',
|
||||||
|
iconColor: '#8B5CF6',
|
||||||
|
iconBg: '#EDE9FE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Delivery Slots',
|
||||||
|
icon: 'schedule',
|
||||||
|
description: 'Configure delivery times',
|
||||||
|
route: '/(drawer)/slots' as any,
|
||||||
|
category: 'quick',
|
||||||
|
iconColor: '#06B6D4',
|
||||||
|
iconBg: '#CFFAFE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Add Product',
|
||||||
|
icon: 'add-circle',
|
||||||
|
description: 'Create a new product listing',
|
||||||
|
route: '/(drawer)/add-product',
|
||||||
|
category: 'quick',
|
||||||
|
iconColor: theme.colors.brand500,
|
||||||
|
iconBg: theme.colors.brand50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Complaints',
|
||||||
|
icon: 'report-problem',
|
||||||
|
description: 'Handle customer complaints',
|
||||||
|
route: '/(drawer)/complaints',
|
||||||
|
category: 'quick',
|
||||||
|
iconColor: '#F59E0B',
|
||||||
|
iconBg: '#FEF3C7',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Products',
|
||||||
|
icon: 'inventory-2',
|
||||||
|
description: 'Manage product catalog',
|
||||||
|
route: '/(drawer)/products',
|
||||||
|
category: 'products',
|
||||||
|
iconColor: '#6366F1',
|
||||||
|
iconBg: '#E0E7FF',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Prices Overview',
|
||||||
|
icon: 'attach-money',
|
||||||
|
description: 'Update product pricing',
|
||||||
|
route: '/(drawer)/prices-overview',
|
||||||
|
category: 'products',
|
||||||
|
iconColor: '#059669',
|
||||||
|
iconBg: '#ECFDF5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Product Tags',
|
||||||
|
icon: 'label',
|
||||||
|
description: 'Organize with tags',
|
||||||
|
route: '/(drawer)/product-tags',
|
||||||
|
category: 'products',
|
||||||
|
iconColor: '#EC4899',
|
||||||
|
iconBg: '#FCE7F3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Product Groupings',
|
||||||
|
icon: 'category',
|
||||||
|
description: 'Group products together',
|
||||||
|
route: '/(drawer)/product-groupings',
|
||||||
|
category: 'products',
|
||||||
|
iconColor: '#8B5CF6',
|
||||||
|
iconBg: '#F3E8FF',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Stores',
|
||||||
|
icon: 'store',
|
||||||
|
description: 'Manage store locations',
|
||||||
|
route: '/(drawer)/stores',
|
||||||
|
category: 'settings',
|
||||||
|
iconColor: '#14B8A6',
|
||||||
|
iconBg: '#CCFBF1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Coupons',
|
||||||
|
icon: 'local-offer',
|
||||||
|
description: 'Create discount coupons',
|
||||||
|
route: '/(drawer)/coupons',
|
||||||
|
category: 'marketing',
|
||||||
|
iconColor: '#F97316',
|
||||||
|
iconBg: '#FFEDD5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Address Management',
|
||||||
|
icon: 'location-on',
|
||||||
|
description: 'Manage service areas',
|
||||||
|
route: '/(drawer)/address-management',
|
||||||
|
category: 'settings',
|
||||||
|
iconColor: '#EAB308',
|
||||||
|
iconBg: '#FEF9C3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'App Constants',
|
||||||
|
icon: 'settings-applications',
|
||||||
|
description: 'Customize app settings',
|
||||||
|
route: '/(drawer)/customize-app',
|
||||||
|
category: 'settings',
|
||||||
|
iconColor: '#7C3AED',
|
||||||
|
iconBg: '#F3E8FF',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickActions = menuItems.filter(item => item.category === 'quick');
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ key: 'orders', title: 'Orders', icon: 'receipt-long' },
|
||||||
|
{ key: 'products', title: 'Products & Inventory', icon: 'inventory' },
|
||||||
|
{ key: 'marketing', title: 'Marketing & Promotions', icon: 'campaign' },
|
||||||
|
{ key: 'settings', title: 'Settings & Configuration', icon: 'settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Header with Gradient */}
|
||||||
|
{/* <LinearGradient
|
||||||
|
colors={[theme.colors.brand500, theme.colors.brand700]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={tw`px-6 pt-8 pb-6`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white text-2xl font-bold mb-1`}>Dashboard</MyText>
|
||||||
|
<MyText style={tw`text-brand-100 text-sm`}>Manage your business operations</MyText>
|
||||||
|
</LinearGradient> */}
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<View style={tw`px-6 mt-2 mb-4`}>
|
||||||
|
<MyText style={tw`text-gray-900 text-lg font-bold mb-4`}>Quick Actions</MyText>
|
||||||
|
<View style={tw`flex-row flex-wrap gap-3`}>
|
||||||
|
{quickActions.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.route}
|
||||||
|
onPress={() => router.push(item.route as any)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
tw`bg-white rounded-xl p-3 shadow-sm border border-gray-100 items-center`,
|
||||||
|
{ width: 'calc(25% - 9px)' },
|
||||||
|
pressed && tw`bg-gray-50`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[tw`w-10 h-10 rounded-lg items-center justify-center mb-2`, { backgroundColor: item.iconBg }]}>
|
||||||
|
<MaterialIcons name={item.icon as any} size={20} color={item.iconColor} />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-gray-900 font-bold text-xs text-center`} numberOfLines={2}>
|
||||||
|
{item.title}
|
||||||
|
</MyText>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Categorized Menu Items */}
|
||||||
|
<View style={tw`px-6 pb-6`}>
|
||||||
|
{categories.map((category) => {
|
||||||
|
const categoryItems = menuItems.filter(item => item.category === category.key);
|
||||||
|
if (categoryItems.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={category.key} style={tw`mb-6`}>
|
||||||
|
<View style={tw`flex-row items-center mb-3`}>
|
||||||
|
<View style={tw`w-8 h-8 rounded-lg bg-gray-100 items-center justify-center mr-3`}>
|
||||||
|
<MaterialIcons name={category.icon as any} size={18} color={theme.colors.gray600} />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-gray-700 font-bold text-base`}>{category.title}</MyText>
|
||||||
|
</View>
|
||||||
|
{categoryItems.map(item => <MenuItemComponent key={item.route} item={item} router={router} />)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Delivery Sequences' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
978
apps/admin-ui/app/(drawer)/delivery-sequences/index.tsx
Normal file
|
|
@ -0,0 +1,978 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
Linking,
|
||||||
|
} from "react-native";
|
||||||
|
import DraggableFlatList, {
|
||||||
|
RenderItemParams,
|
||||||
|
ScaleDecorator,
|
||||||
|
} from "react-native-draggable-flatlist";
|
||||||
|
import {
|
||||||
|
AppContainer,
|
||||||
|
MyText,
|
||||||
|
tw,
|
||||||
|
useManualRefresh,
|
||||||
|
useMarkDataFetchers,
|
||||||
|
BottomDialog,
|
||||||
|
Checkbox,
|
||||||
|
BottomDropdown,
|
||||||
|
} from "common-ui";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { trpc } from "@/src/trpc-client";
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import Entypo from "@expo/vector-icons/Entypo";
|
||||||
|
import * as Location from "expo-location";
|
||||||
|
|
||||||
|
// Define types outside
|
||||||
|
interface OrderWithSequence {
|
||||||
|
sequenceId: number;
|
||||||
|
readableId: number;
|
||||||
|
isPackaged: boolean;
|
||||||
|
totalAmount: number;
|
||||||
|
address: string;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
id: number;
|
||||||
|
isDelivered: boolean;
|
||||||
|
addressId: number;
|
||||||
|
adminNotes?: string | null;
|
||||||
|
customerName?: string | null;
|
||||||
|
assignedUserName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotProgressProps {
|
||||||
|
slotId: number;
|
||||||
|
orders: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SlotProgress: React.FC<SlotProgressProps> = ({ slotId, orders }) => {
|
||||||
|
const delivered = orders.filter((o) => o.isDelivered).length;
|
||||||
|
const undelivered = orders.filter((o) => !o.isDelivered).length;
|
||||||
|
const packaged = orders.filter((o) => o.isPackaged).length;
|
||||||
|
const notPackaged = orders.filter((o) => !o.isPackaged).length;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: "Delivered", value: delivered },
|
||||||
|
{ label: "Undelivered", value: undelivered },
|
||||||
|
{ label: "Packaged", value: packaged },
|
||||||
|
{ label: "Not Packaged", value: notPackaged },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`bg-white px-4 py-3 border-b border-gray-200 mb-2`}>
|
||||||
|
<MyText style={tw`text-gray-700 text-sm mb-3`}>
|
||||||
|
Slot {slotId} Statistics
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row flex-wrap`}>
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<View key={index} style={tw`w-1/2 p-2`}>
|
||||||
|
<View style={tw`bg-gray-50 p-3 rounded-lg border border-gray-200`}>
|
||||||
|
<MyText style={tw`text-gray-900 font-bold text-lg`}>
|
||||||
|
{stat.value}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>{stat.label}</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OrderItemProps {
|
||||||
|
item: OrderWithSequence;
|
||||||
|
drag: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
setSelectedOrder: (order: OrderWithSequence | null) => void;
|
||||||
|
setShowOrderMenu: (show: boolean) => void;
|
||||||
|
selectedOrderIds: number[];
|
||||||
|
onToggleOrder: (id: number) => void;
|
||||||
|
assignedUserName?: string | null;
|
||||||
|
staffData?: { staff?: Array<{ id: number; name: string }> };
|
||||||
|
setSelectedUserId?: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrderItem: React.FC<OrderItemProps> = ({
|
||||||
|
item,
|
||||||
|
drag,
|
||||||
|
isActive,
|
||||||
|
isPending,
|
||||||
|
setSelectedOrder,
|
||||||
|
setShowOrderMenu,
|
||||||
|
selectedOrderIds,
|
||||||
|
onToggleOrder,
|
||||||
|
assignedUserName,
|
||||||
|
staffData,
|
||||||
|
setSelectedUserId,
|
||||||
|
}) => {
|
||||||
|
const orderItem = item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScaleDecorator>
|
||||||
|
<TouchableOpacity
|
||||||
|
onLongPress={drag}
|
||||||
|
disabled={isPending}
|
||||||
|
activeOpacity={1}
|
||||||
|
style={tw`mx-4 my-2`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
tw`bg-white p-4 rounded-xl border`,
|
||||||
|
isActive
|
||||||
|
? tw`shadow-xl border-blue-500 z-50`
|
||||||
|
: tw`shadow-sm border-gray-100`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
{/* Checkbox and Drag Handle */}
|
||||||
|
<View style={tw`mr-4 flex-col items-center`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedOrderIds.includes(orderItem.id)}
|
||||||
|
onPress={() => onToggleOrder(orderItem.id)}
|
||||||
|
size={20}
|
||||||
|
fillColor="#3b82f6"
|
||||||
|
checkColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
<View style={tw`mt-1`}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="drag-indicator"
|
||||||
|
size={24}
|
||||||
|
color={isActive ? "#3b82f6" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||||
|
<View style={tw`flex-row items-center gap-2`}>
|
||||||
|
<MyText style={tw`font-bold text-gray-800 text-lg`}>
|
||||||
|
#{orderItem.readableId}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row items-center gap-1`}>
|
||||||
|
<MyText style={tw`text-xs font-medium text-gray-600`}>
|
||||||
|
Pkg
|
||||||
|
</MyText>
|
||||||
|
<Checkbox
|
||||||
|
checked={orderItem.isPackaged}
|
||||||
|
size={16}
|
||||||
|
fillColor="#6B7280"
|
||||||
|
checkColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{/* Optional: Add time if available, or just keep ID */}
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<View
|
||||||
|
style={tw`bg-green-50 px-2 py-1 rounded-lg border border-green-100 mr-2`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`font-bold text-green-700 text-sm`}>
|
||||||
|
₹{orderItem.totalAmount}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedOrder(orderItem);
|
||||||
|
setShowOrderMenu(true);
|
||||||
|
}}
|
||||||
|
style={tw`p-2`}
|
||||||
|
>
|
||||||
|
<Entypo
|
||||||
|
name="dots-three-vertical"
|
||||||
|
size={20}
|
||||||
|
color="#6b7280"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-start`}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="location-on"
|
||||||
|
size={14}
|
||||||
|
color="#6b7280"
|
||||||
|
style={tw`mr-1 mt-0.5`}
|
||||||
|
/>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-gray-500 text-xs flex-1`}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{orderItem.address}
|
||||||
|
</MyText>
|
||||||
|
{orderItem.latitude && orderItem.longitude && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
Linking.openURL(
|
||||||
|
`https://www.google.com/maps?q=${orderItem.latitude},${orderItem.longitude}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={tw`ml-2`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="map" size={16} color="#3b82f6" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{assignedUserName && (
|
||||||
|
<View style={tw`mt-2 flex-row items-center`}>
|
||||||
|
<MyText style={tw`text-xs text-gray-500`}>
|
||||||
|
Assigned to{" "}
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
const assignedUserId = staffData?.staff?.find(
|
||||||
|
(s: { id: number; name: string }) => s.name === assignedUserName
|
||||||
|
)?.id;
|
||||||
|
if (assignedUserId && setSelectedUserId) {
|
||||||
|
setSelectedUserId(assignedUserId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-xs text-blue-600 underline`}>
|
||||||
|
{assignedUserName}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* Admin Notes */}
|
||||||
|
{orderItem.adminNotes && (
|
||||||
|
<View
|
||||||
|
style={tw`bg-yellow-50 p-2 rounded-lg border border-yellow-100 mt-2`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-xs text-yellow-900 leading-4`}>
|
||||||
|
{orderItem.adminNotes}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScaleDecorator>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeliverySequences() {
|
||||||
|
const { slotId } = useLocalSearchParams();
|
||||||
|
const selectedSlotId = slotId ? Number(slotId) : null;
|
||||||
|
const [localOrderedOrders, setLocalOrderedOrders] = useState<
|
||||||
|
OrderWithSequence[]
|
||||||
|
>([]);
|
||||||
|
const [showOrderMenu, setShowOrderMenu] = useState(false);
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState<OrderWithSequence | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<number>(-1);
|
||||||
|
const [selectedOrderIds, setSelectedOrderIds] = useState<number[]>([]);
|
||||||
|
const [hasSequenceChanged, setHasSequenceChanged] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { data: slotsData, refetch: refetchSlots } =
|
||||||
|
trpc.admin.slots.getAll.useQuery();
|
||||||
|
const {
|
||||||
|
data: ordersData,
|
||||||
|
isLoading: ordersLoading,
|
||||||
|
refetch: refetchOrders,
|
||||||
|
} = trpc.admin.order.getSlotOrders.useQuery(
|
||||||
|
{ slotId: String(selectedSlotId) },
|
||||||
|
{
|
||||||
|
enabled: !!selectedSlotId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { data: sequenceData, refetch: refetchSequence } =
|
||||||
|
trpc.admin.slots.getDeliverySequence.useQuery(
|
||||||
|
{ id: String(selectedSlotId) },
|
||||||
|
{
|
||||||
|
enabled: !!selectedSlotId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
|
||||||
|
|
||||||
|
// Auto-select first slot if no slotId provided
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slotId && slotsData?.slots && slotsData.slots.length > 0) {
|
||||||
|
router.replace(`/delivery-sequences?slotId=${slotsData.slots[0].id}`);
|
||||||
|
}
|
||||||
|
}, [slotId, slotsData, router]);
|
||||||
|
|
||||||
|
const updateSequenceMutation =
|
||||||
|
trpc.admin.slots.updateDeliverySequence.useMutation();
|
||||||
|
const updatePackagedMutation = trpc.admin.order.updatePackaged.useMutation();
|
||||||
|
const updateDeliveredMutation =
|
||||||
|
trpc.admin.order.updateDelivered.useMutation();
|
||||||
|
const updateAddressCoordsMutation =
|
||||||
|
trpc.admin.order.updateAddressCoords.useMutation();
|
||||||
|
|
||||||
|
// Manual refresh functionality
|
||||||
|
useManualRefresh(() => {
|
||||||
|
refetchSlots();
|
||||||
|
refetchOrders();
|
||||||
|
refetchSequence();
|
||||||
|
});
|
||||||
|
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetchSlots();
|
||||||
|
refetchOrders();
|
||||||
|
refetchSequence();
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = slotsData?.slots || [];
|
||||||
|
const orders = ordersData?.data || [];
|
||||||
|
const deliverySequence = sequenceData?.deliverySequence || [];
|
||||||
|
|
||||||
|
const slotOptions =
|
||||||
|
slots?.map((slot) => ({
|
||||||
|
label: dayjs(slot.deliveryTime).format("ddd DD MMM, h:mm a"),
|
||||||
|
value: slot.id,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const userOptions = [
|
||||||
|
{ label: "Unassigned", value: -1 },
|
||||||
|
{ label: "All Users", value: -2 },
|
||||||
|
...(staffData?.staff?.map((staff) => ({
|
||||||
|
label: staff.name,
|
||||||
|
value: staff.id,
|
||||||
|
})) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create ordered orders based on delivery sequence
|
||||||
|
const computedOrderedOrders = useMemo(() => {
|
||||||
|
if (orders.length > 0) {
|
||||||
|
const userSequence =
|
||||||
|
selectedUserId !== -1
|
||||||
|
? (deliverySequence as any)?.[String(selectedUserId)] || []
|
||||||
|
: null;
|
||||||
|
if (selectedUserId !== -1 && userSequence && userSequence.length > 0) {
|
||||||
|
// Sort orders according to user's sequence
|
||||||
|
const sequenceMap = new Map(
|
||||||
|
userSequence.map((id: number, index: number) => [id, index])
|
||||||
|
);
|
||||||
|
let ordered = orders
|
||||||
|
.filter((order) => userSequence.includes(order.id))
|
||||||
|
.map((order) => ({ ...order, sequenceId: order.id }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIndex = sequenceMap.get(a.sequenceId) ?? Infinity;
|
||||||
|
const bIndex = sequenceMap.get(b.sequenceId) ?? Infinity;
|
||||||
|
return aIndex < bIndex ? -1 : aIndex > bIndex ? 1 : 0;
|
||||||
|
});
|
||||||
|
return ordered;
|
||||||
|
} else if (selectedUserId === -1) {
|
||||||
|
// Show unassigned orders (not in any user's sequence)
|
||||||
|
const assignedIds = new Set(
|
||||||
|
Object.values(deliverySequence as any).flat()
|
||||||
|
);
|
||||||
|
let ordered = orders
|
||||||
|
.filter((order) => !assignedIds.has(order.id))
|
||||||
|
.map((order) => ({ ...order, sequenceId: order.id }))
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.sequenceId < b.sequenceId
|
||||||
|
? -1
|
||||||
|
: a.sequenceId > b.sequenceId
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
return ordered;
|
||||||
|
} else if (selectedUserId === -2) {
|
||||||
|
// Show all orders with assignment info
|
||||||
|
const orderToUserMap = new Map<number, number>();
|
||||||
|
Object.entries(deliverySequence as any).forEach(([userId, orderIds]) => {
|
||||||
|
(orderIds as number[]).forEach((orderId) => {
|
||||||
|
orderToUserMap.set(orderId, parseInt(userId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let ordered = orders
|
||||||
|
.map((order) => {
|
||||||
|
const assignedUserId = orderToUserMap.get(order.id);
|
||||||
|
const assignedUser = staffData?.staff?.find(
|
||||||
|
(s) => s.id === assignedUserId
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
sequenceId: order.id,
|
||||||
|
assignedUserName: assignedUser?.name || null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.sequenceId < b.sequenceId
|
||||||
|
? -1
|
||||||
|
: a.sequenceId > b.sequenceId
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
return ordered;
|
||||||
|
} else {
|
||||||
|
// No sequence for user, show empty
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [ordersData, sequenceData, selectedUserId]);
|
||||||
|
|
||||||
|
// Sync local state with computed orders
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalOrderedOrders(computedOrderedOrders);
|
||||||
|
}, [computedOrderedOrders]);
|
||||||
|
|
||||||
|
const handleDragEnd = ({ data }: { data: OrderWithSequence[] }) => {
|
||||||
|
if (selectedUserId !== -1 && selectedUserId !== -2) {
|
||||||
|
setLocalOrderedOrders(data);
|
||||||
|
setHasSequenceChanged(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSequence = () => {
|
||||||
|
if (!selectedSlotId || selectedUserId === -1) return;
|
||||||
|
|
||||||
|
const newSequence = { ...(deliverySequence as any) };
|
||||||
|
newSequence[String(selectedUserId)] = localOrderedOrders.map(
|
||||||
|
(order) => order.sequenceId
|
||||||
|
);
|
||||||
|
|
||||||
|
updateSequenceMutation.mutate(
|
||||||
|
{
|
||||||
|
id: selectedSlotId,
|
||||||
|
deliverySequence: newSequence,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setHasSequenceChanged(false);
|
||||||
|
Alert.alert("Success", "Delivery sequence updated successfully");
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
`Failed to update delivery sequence: ${
|
||||||
|
error.message || "Unknown error"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!slotsData) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-gray-50 pt-6`}>
|
||||||
|
<ActivityIndicator size="large" color="#3b82f6" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4`}>Loading slots...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slotsData.slots.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center p-8 pt-6`}>
|
||||||
|
<MaterialIcons name="event-busy" size={64} color="#e5e7eb" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
|
||||||
|
No slots available
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-gray-50 pt-6`}>
|
||||||
|
{selectedSlotId ? (
|
||||||
|
<>
|
||||||
|
{/* Header Section */}
|
||||||
|
<View
|
||||||
|
style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center shadow-sm z-10`}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={tw`p-2 -ml-4`}
|
||||||
|
accessibilityLabel="Go back"
|
||||||
|
>
|
||||||
|
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={tw`flex-2 mx-2`}>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Select Slot"
|
||||||
|
options={slotOptions}
|
||||||
|
value={selectedSlotId || ""}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val) {
|
||||||
|
router.replace(`/delivery-sequences?slotId=${val}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Select slot"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1 mx-2`}>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Select User"
|
||||||
|
options={userOptions}
|
||||||
|
value={selectedUserId}
|
||||||
|
onValueChange={(val) => setSelectedUserId(val as number)}
|
||||||
|
placeholder="Select user"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
{ordersLoading ? (
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<ActivityIndicator size="large" color="#3b82f6" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4`}>Loading orders...</MyText>
|
||||||
|
</View>
|
||||||
|
) : localOrderedOrders.length === 0 ? (
|
||||||
|
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||||
|
<MaterialIcons name="assignment-late" size={64} color="#e5e7eb" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
|
||||||
|
No orders found for this slot
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
{/* <View style={tw`bg-blue-50 px-4 py-2 mb-2`}>
|
||||||
|
<MyText style={tw`text-blue-700 text-xs text-center`}>
|
||||||
|
Long press an item to drag and reorder
|
||||||
|
</MyText>
|
||||||
|
</View> */}
|
||||||
|
<DraggableFlatList
|
||||||
|
data={localOrderedOrders}
|
||||||
|
renderItem={({ item, drag, isActive }) => (
|
||||||
|
<OrderItem
|
||||||
|
item={item}
|
||||||
|
drag={drag}
|
||||||
|
isActive={isActive}
|
||||||
|
isPending={updateSequenceMutation.isPending}
|
||||||
|
setSelectedOrder={setSelectedOrder}
|
||||||
|
setShowOrderMenu={setShowOrderMenu}
|
||||||
|
selectedOrderIds={selectedOrderIds}
|
||||||
|
onToggleOrder={(id) => {
|
||||||
|
setSelectedOrderIds((prev) =>
|
||||||
|
prev.includes(id)
|
||||||
|
? prev.filter((i) => i !== id)
|
||||||
|
: [...prev, id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
assignedUserName={selectedUserId === -2 ? item.assignedUserName : undefined}
|
||||||
|
staffData={staffData}
|
||||||
|
setSelectedUserId={setSelectedUserId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.sequenceId.toString()}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={tw`pb-8`}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<SlotProgress slotId={selectedSlotId!} orders={orders} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB for Assignment */}
|
||||||
|
{selectedOrderIds.length > 0 && (
|
||||||
|
<View style={tw`absolute bottom-4 right-4`}>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Assign To"
|
||||||
|
options={[
|
||||||
|
...userOptions.filter((opt) => opt.value !== -1),
|
||||||
|
{ label: "None", value: -3 },
|
||||||
|
]}
|
||||||
|
value={-1}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val !== -1) {
|
||||||
|
if (val === -3) {
|
||||||
|
// Unassign selected orders
|
||||||
|
const newSequence = { ...(deliverySequence as any) };
|
||||||
|
Object.keys(newSequence).forEach((userId) => {
|
||||||
|
newSequence[userId] = newSequence[userId].filter(
|
||||||
|
(id: number) => !selectedOrderIds.includes(id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Save
|
||||||
|
updateSequenceMutation.mutate(
|
||||||
|
{
|
||||||
|
id: selectedSlotId,
|
||||||
|
deliverySequence: newSequence,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedOrderIds([]);
|
||||||
|
refetchSequence();
|
||||||
|
Alert.alert(
|
||||||
|
"Success",
|
||||||
|
"Orders unassigned successfully"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
`Failed to unassign orders: ${
|
||||||
|
error.message || "Unknown error"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Assign selected orders to the user
|
||||||
|
// Update deliverySequence map
|
||||||
|
const newSequence = { ...(deliverySequence as any) };
|
||||||
|
// Remove from all other users
|
||||||
|
Object.keys(newSequence).forEach((userId) => {
|
||||||
|
newSequence[userId] = newSequence[userId].filter(
|
||||||
|
(id: number) => !selectedOrderIds.includes(id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Add to selected user
|
||||||
|
if (!newSequence[String(val)])
|
||||||
|
newSequence[String(val)] = [];
|
||||||
|
newSequence[String(val)] = [
|
||||||
|
...new Set([
|
||||||
|
...newSequence[String(val)],
|
||||||
|
...selectedOrderIds,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
// Save
|
||||||
|
updateSequenceMutation.mutate(
|
||||||
|
{
|
||||||
|
id: selectedSlotId,
|
||||||
|
deliverySequence: newSequence,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedOrderIds([]);
|
||||||
|
refetchSequence();
|
||||||
|
Alert.alert(
|
||||||
|
"Success",
|
||||||
|
"Orders assigned successfully"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
`Failed to assign orders: ${
|
||||||
|
error.message || "Unknown error"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
triggerComponent={({ onPress }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
style={tw`bg-blue-600 p-4 rounded-full shadow-lg`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="person-add" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB for Save */}
|
||||||
|
{hasSequenceChanged && (
|
||||||
|
<View style={tw`absolute bottom-4 left-4`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSaveSequence}
|
||||||
|
style={tw`bg-blue-600 p-4 rounded-full shadow-lg`}
|
||||||
|
disabled={updateSequenceMutation.isPending}
|
||||||
|
accessibilityLabel="Save sequence"
|
||||||
|
>
|
||||||
|
<MaterialIcons name="save" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||||
|
<MaterialIcons name="event-busy" size={64} color="#e5e7eb" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
|
||||||
|
No slot selected. Please select a slot to view delivery sequence.
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-bold`}>Go Back</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order Menu Dialog */}
|
||||||
|
<BottomDialog
|
||||||
|
open={showOrderMenu}
|
||||||
|
onClose={() => setShowOrderMenu(false)}
|
||||||
|
>
|
||||||
|
<View style={tw`pb-8 pt-2 px-4`}>
|
||||||
|
{/* Handle Bar */}
|
||||||
|
<View style={tw`items-center mb-6`}>
|
||||||
|
<View style={tw`w-12 h-1.5 bg-gray-200 rounded-full mb-4`} />
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-900`}>
|
||||||
|
Order #{selectedOrder?.readableId}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-500`}>
|
||||||
|
Select an action to perform
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
||||||
|
onPress={() => {
|
||||||
|
router.push(`/order-details/${selectedOrder?.id}`);
|
||||||
|
setShowOrderMenu(false);
|
||||||
|
}}
|
||||||
|
disabled={updateAddressCoordsMutation.isPending}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={tw`w-10 h-10 rounded-full bg-purple-50 items-center justify-center mr-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="visibility" size={20} color="#9333ea" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
View Details
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
See full order information
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color="#9ca3af"
|
||||||
|
style={tw`ml-auto`}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm ${
|
||||||
|
updatePackagedMutation.isPending ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
onPress={async () => {
|
||||||
|
if (!selectedOrder) return;
|
||||||
|
try {
|
||||||
|
await updatePackagedMutation.mutateAsync({
|
||||||
|
orderId: selectedOrder.id.toString(),
|
||||||
|
isPackaged: !selectedOrder.isPackaged,
|
||||||
|
});
|
||||||
|
refetchOrders();
|
||||||
|
refetchSequence();
|
||||||
|
setShowOrderMenu(false);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert("Error", "Failed to update packaged status");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={updatePackagedMutation.isPending}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={tw`w-10 h-10 rounded-full bg-blue-50 items-center justify-center mr-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="inventory" size={20} color="#2563eb" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
{selectedOrder?.isPackaged
|
||||||
|
? "Unmark Packaged"
|
||||||
|
: "Mark Packaged"}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
{selectedOrder?.isPackaged
|
||||||
|
? "Revert to not packaged"
|
||||||
|
: "Update status to packaged"}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color="#9ca3af"
|
||||||
|
style={tw`ml-auto`}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm ${
|
||||||
|
updateDeliveredMutation.isPending ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
onPress={async () => {
|
||||||
|
if (!selectedOrder) return;
|
||||||
|
try {
|
||||||
|
await updateDeliveredMutation.mutateAsync({
|
||||||
|
orderId: selectedOrder.id.toString(),
|
||||||
|
isDelivered: !selectedOrder.isDelivered,
|
||||||
|
});
|
||||||
|
refetchOrders();
|
||||||
|
refetchSequence();
|
||||||
|
setShowOrderMenu(false);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert("Error", "Failed to update delivered status");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={updateDeliveredMutation.isPending}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={tw`w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="local-shipping" size={20} color="#16a34a" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
{selectedOrder?.isDelivered
|
||||||
|
? "Unmark Delivered"
|
||||||
|
: "Mark Delivered"}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
{selectedOrder?.isDelivered
|
||||||
|
? "Revert delivery status"
|
||||||
|
: "Complete the delivery"}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color="#9ca3af"
|
||||||
|
style={tw`ml-auto`}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm ${
|
||||||
|
updateAddressCoordsMutation.isPending ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
onPress={async () => {
|
||||||
|
if (!selectedOrder) return;
|
||||||
|
try {
|
||||||
|
const { status } =
|
||||||
|
await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (status !== "granted") {
|
||||||
|
Alert.alert(
|
||||||
|
"Permission Denied",
|
||||||
|
"Location permission is required to attach coordinates."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const location = await Location.getCurrentPositionAsync({
|
||||||
|
accuracy: Location.Accuracy.High,
|
||||||
|
});
|
||||||
|
const { latitude, longitude } = location.coords;
|
||||||
|
await updateAddressCoordsMutation.mutateAsync({
|
||||||
|
addressId: selectedOrder.addressId,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
});
|
||||||
|
Alert.alert(
|
||||||
|
"Success",
|
||||||
|
"Location attached to address successfully."
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
"Failed to attach location. Please try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setShowOrderMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={tw`w-10 h-10 rounded-full bg-orange-50 items-center justify-center mr-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="add-location-alt"
|
||||||
|
size={20}
|
||||||
|
color="#ea580c"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Attach Location
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
Save coordinates to address
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color="#9ca3af"
|
||||||
|
style={tw`ml-auto`}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
||||||
|
onPress={() => {
|
||||||
|
const phoneMatch = selectedOrder?.address.match(/Phone: (\d+)/);
|
||||||
|
const phone = phoneMatch ? phoneMatch[1] : null;
|
||||||
|
if (phone) {
|
||||||
|
Linking.openURL(`whatsapp://send?phone=+91${phone}`);
|
||||||
|
} else {
|
||||||
|
Alert.alert("No phone number found");
|
||||||
|
}
|
||||||
|
setShowOrderMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={tw`w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="message" size={20} color="#16a34a" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Message On WhatsApp
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
Send message via WhatsApp
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color="#9ca3af"
|
||||||
|
style={tw`ml-auto`}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`}
|
||||||
|
onPress={() => {
|
||||||
|
const phoneMatch = selectedOrder?.address.match(/Phone: (\d+)/);
|
||||||
|
const phone = phoneMatch ? phoneMatch[1] : null;
|
||||||
|
if (phone) {
|
||||||
|
Linking.openURL(`tel:${phone}`);
|
||||||
|
} else {
|
||||||
|
Alert.alert("No phone number found");
|
||||||
|
}
|
||||||
|
setShowOrderMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={tw`w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="phone" size={20} color="#16a34a" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`font-semibold text-gray-800 text-base`}>
|
||||||
|
Dial Mobile Number
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-xs`}>
|
||||||
|
Call customer directly
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color="#9ca3af"
|
||||||
|
style={tw`ml-auto`}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/admin-ui/app/(drawer)/edit-coupon/[id]/index.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Alert } from 'react-native';
|
||||||
|
import { tw, AppContainer, MyText } from 'common-ui';
|
||||||
|
import CouponForm from '../../../../src/components/CouponForm';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { CreateCouponPayload } from 'common-ui/shared-types';
|
||||||
|
|
||||||
|
export default function EditCoupon() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const couponId = parseInt(id as string);
|
||||||
|
|
||||||
|
const { data: coupon, isLoading } = trpc.admin.coupon.getById.useQuery({ id: couponId });
|
||||||
|
const updateCoupon = trpc.admin.coupon.update.useMutation();
|
||||||
|
|
||||||
|
const handleUpdateCoupon = (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => {
|
||||||
|
// Transform targetUsers array to targetUser for backend compatibility
|
||||||
|
const updates = {
|
||||||
|
...values,
|
||||||
|
targetUser: values.targetUsers?.[0] || undefined,
|
||||||
|
};
|
||||||
|
delete updates.targetUsers;
|
||||||
|
|
||||||
|
updateCoupon.mutate({ id: couponId, updates }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert('Success', 'Coupon updated successfully', [
|
||||||
|
{ text: 'OK', onPress: () => router.back() }
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to update coupon');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText>Loading...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText>Coupon not found</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform coupon data for form (targetUser to targetUsers array)
|
||||||
|
const initialValues: Partial<CreateCouponPayload & { isReservedCoupon?: boolean }> = {
|
||||||
|
couponCode: coupon.couponCode,
|
||||||
|
isUserBased: coupon.isUserBased,
|
||||||
|
isApplyForAll: coupon.isApplyForAll,
|
||||||
|
targetUsers: coupon.targetUser ? [coupon.targetUser.id] : [],
|
||||||
|
discountPercent: coupon.discountPercent ? parseFloat(coupon.discountPercent) : undefined,
|
||||||
|
flatDiscount: coupon.flatDiscount ? parseFloat(coupon.flatDiscount) : undefined,
|
||||||
|
minOrder: coupon.minOrder ? parseFloat(coupon.minOrder) : undefined,
|
||||||
|
maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined,
|
||||||
|
validTill: coupon.validTill ? dayjs(coupon.validTill).format('YYYY-MM-DD') : undefined,
|
||||||
|
maxLimitForUser: coupon.maxLimitForUser || undefined,
|
||||||
|
productIds: coupon.productIds,
|
||||||
|
isReservedCoupon: false, // Normal coupons
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<CouponForm
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleUpdateCoupon}
|
||||||
|
isLoading={updateCoupon.isPending}
|
||||||
|
/>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/admin-ui/app/(drawer)/edit-product-group/[id].tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { AppContainer, MyText } from 'common-ui';
|
||||||
|
import ProductGroupForm from '../../../components/ProductGroupForm';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { trpc } from '../../../src/trpc-client';
|
||||||
|
|
||||||
|
export default function EditProductGroup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
|
||||||
|
const groupId = parseInt(id as string);
|
||||||
|
|
||||||
|
const { data: groupsData, isLoading } = trpc.admin.product.getGroups.useQuery();
|
||||||
|
const groups = groupsData?.groups || [];
|
||||||
|
const group = groups.find(g => g.id === groupId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<MyText>Loading group...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<MyText>Group not found</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<ProductGroupForm
|
||||||
|
group={group}
|
||||||
|
onClose={() => router.back()}
|
||||||
|
onSuccess={() => router.back()}
|
||||||
|
/>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-ui/app/(drawer)/edit-product/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Edit Product",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/admin-ui/app/(drawer)/edit-product/index.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { View, Text, Alert } from 'react-native';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
||||||
|
import ProductForm, { ProductFormRef } from '../../../src/components/ProductForm';
|
||||||
|
import { useUpdateProduct } from '../../../src/api-hooks/product.api';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
|
export default function EditProduct() {
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const productId = Number(id);
|
||||||
|
const productFormRef = useRef<ProductFormRef>(null);
|
||||||
|
|
||||||
|
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
|
||||||
|
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||||
|
{ id: productId },
|
||||||
|
{ enabled: !!productId }
|
||||||
|
);
|
||||||
|
//
|
||||||
|
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
|
||||||
|
|
||||||
|
useManualRefresh(() => refetch());
|
||||||
|
|
||||||
|
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
|
||||||
|
const payload = {
|
||||||
|
name: values.name,
|
||||||
|
shortDescription: values.shortDescription,
|
||||||
|
longDescription: values.longDescription,
|
||||||
|
unitId: parseInt(values.unitId),
|
||||||
|
storeId: parseInt(values.storeId),
|
||||||
|
price: parseFloat(values.price),
|
||||||
|
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||||
|
incrementStep: 1,
|
||||||
|
productQuantity: values.productQuantity || 1,
|
||||||
|
deals: values.deals?.filter((deal: any) =>
|
||||||
|
deal.quantity && deal.price && deal.validTill
|
||||||
|
).map((deal: any) => ({
|
||||||
|
quantity: parseInt(deal.quantity),
|
||||||
|
price: parseFloat(deal.price),
|
||||||
|
validTill: deal.validTill instanceof Date
|
||||||
|
? deal.validTill.toISOString().split('T')[0]
|
||||||
|
: deal.validTill, // Convert Date to YYYY-MM-DD string
|
||||||
|
})),
|
||||||
|
tagIds: values.tagIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log({payload})
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
|
if (key === 'deals' && Array.isArray(value)) {
|
||||||
|
formData.append(key, JSON.stringify(value));
|
||||||
|
} else if (key === 'tagIds' && Array.isArray(value)) {
|
||||||
|
value.forEach(tagId => {
|
||||||
|
formData.append('tagIds', tagId.toString());
|
||||||
|
});
|
||||||
|
} else if (value !== undefined && value !== null) {
|
||||||
|
formData.append(key, value as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new images
|
||||||
|
if (newImages && newImages.length > 0) {
|
||||||
|
newImages.forEach((image, index) => {
|
||||||
|
if (image.uri) {
|
||||||
|
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
|
||||||
|
formData.append('images', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: fileName,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add images to delete
|
||||||
|
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||||
|
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProduct(
|
||||||
|
{ id: productId, formData },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Alert.alert('Success', 'Product updated successfully!');
|
||||||
|
// Clear newly added images after successful update
|
||||||
|
productFormRef.current?.clearImages();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to update product');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-gray-600`}>Loading product...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-red-600`}>Product not found</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const productData = product.product; // The API returns { product: Product }
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
name: productData.name,
|
||||||
|
shortDescription: productData.shortDescription || '',
|
||||||
|
longDescription: productData.longDescription || '',
|
||||||
|
unitId: productData.unitId,
|
||||||
|
storeId: productData.storeId || 1,
|
||||||
|
price: productData.price.toString(),
|
||||||
|
marketPrice: productData.marketPrice?.toString() || '',
|
||||||
|
deals: productData.deals?.map(deal => ({
|
||||||
|
quantity: deal.quantity,
|
||||||
|
price: deal.price,
|
||||||
|
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
|
||||||
|
})) || [{ quantity: '', price: '', validTill: null }],
|
||||||
|
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||||
|
isSuspended: productData.isSuspended || false,
|
||||||
|
isFlashAvailable: productData.isFlashAvailable || false,
|
||||||
|
flashPrice: productData.flashPrice?.toString() || '',
|
||||||
|
productQuantity: productData.productQuantity || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<ProductForm
|
||||||
|
ref={productFormRef}
|
||||||
|
mode="edit"
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
existingImages={productData.images || []}
|
||||||
|
/>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/admin-ui/app/(drawer)/edit-slot/[id]/index.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
import { AppContainer } from 'common-ui';
|
||||||
|
// import SlotForm from '../../../components/SlotForm';
|
||||||
|
// import { trpc } from '../../../src/trpc-client';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import SlotForm from '@/components/SlotForm';
|
||||||
|
|
||||||
|
export default function EditSlot() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const slotId = parseInt(id as string);
|
||||||
|
|
||||||
|
const { data: slot, isLoading } = trpc.admin.slots.getSlotById.useQuery({ id: slotId });
|
||||||
|
|
||||||
|
const handleSlotUpdated = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<Text>Loading slot...</Text>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slot) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<Text>Slot not found</Text>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<SlotForm
|
||||||
|
initialDeliveryTime={new Date(slot.slot.deliveryTime)}
|
||||||
|
initialFreezeTime={new Date(slot.slot.freezeTime)}
|
||||||
|
initialIsActive={slot.slot.isActive}
|
||||||
|
slotId={slot.slot.id}
|
||||||
|
initialProductIds={slot.slot.products?.map(p => p.id) || []}
|
||||||
|
onSlotAdded={handleSlotUpdated}
|
||||||
|
/>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/edit-slot/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="[id]" options={{ title: 'Edit Slot' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/edit-store/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Edit Store' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/admin-ui/app/(drawer)/edit-store/index.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Alert } from 'react-native';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { AppContainer, MyText, tw } from 'common-ui';
|
||||||
|
import StoreForm, { StoreFormData } from '@/components/StoreForm';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
|
export default function EditStore() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
|
||||||
|
const storeId = parseInt(id as string);
|
||||||
|
|
||||||
|
const { data: storeData, isLoading: isLoadingStore, refetch } = trpc.admin.store.getStoreById.useQuery(
|
||||||
|
{ id: storeId },
|
||||||
|
{ enabled: !!storeId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateStoreMutation = trpc.admin.store.updateStore.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = (values: StoreFormData) => {
|
||||||
|
updateStoreMutation.mutate({ id: storeId, ...values }, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
refetch();
|
||||||
|
Alert.alert('Success', data.message);
|
||||||
|
router.push('/(drawer)/stores' as any);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to update store');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingStore) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText>Loading store...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storeData?.store) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText>Store not found</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
name: storeData.store.name,
|
||||||
|
description: storeData.store.description || '',
|
||||||
|
imageUrl: storeData.store.imageUrl || '',
|
||||||
|
owner: storeData.store.owner.id,
|
||||||
|
products: [], // Will be set by StoreForm
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-2xl font-bold text-gray-800 mb-6`}>Edit Store</MyText>
|
||||||
|
<StoreForm
|
||||||
|
mode="edit"
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={updateStoreMutation.isPending}
|
||||||
|
storeId={storeId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-ui/app/(drawer)/edit-tag/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Edit Tag",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/admin-ui/app/(drawer)/edit-tag/index.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Alert } from 'react-native';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { AppContainer, MyText, tw } from 'common-ui';
|
||||||
|
import TagForm from '@/src/components/TagForm';
|
||||||
|
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
|
||||||
|
|
||||||
|
interface TagFormData {
|
||||||
|
tagName: string;
|
||||||
|
tagDescription: string;
|
||||||
|
isDashboardTag: boolean;
|
||||||
|
existingImageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditTag() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { tagId } = useLocalSearchParams<{ tagId: string }>();
|
||||||
|
const tagIdNum = tagId ? parseInt(tagId) : null;
|
||||||
|
|
||||||
|
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
|
||||||
|
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
|
||||||
|
|
||||||
|
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||||
|
if (!tagIdNum) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Add text fields
|
||||||
|
formData.append('tagName', values.tagName);
|
||||||
|
if (values.tagDescription) {
|
||||||
|
formData.append('tagDescription', values.tagDescription);
|
||||||
|
}
|
||||||
|
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||||
|
|
||||||
|
// Add image if uploaded
|
||||||
|
if (image?.uri) {
|
||||||
|
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||||
|
const match = /\.(\w+)$/.exec(filename);
|
||||||
|
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||||
|
|
||||||
|
formData.append('image', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: filename,
|
||||||
|
type,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTag({ id: tagIdNum, formData }, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Alert.alert('Success', 'Tag updated successfully', [
|
||||||
|
{
|
||||||
|
text: 'OK',
|
||||||
|
onPress: () => router.back(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const errorMessage = error.message || 'Failed to update tag';
|
||||||
|
Alert.alert('Error', errorMessage);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingTag) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-gray-500`}>Loading tag...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagError || !tagData?.tag) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-gray-500`}>Tag not found</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = tagData.tag;
|
||||||
|
const initialValues: TagFormData = {
|
||||||
|
tagName: tag.tagName,
|
||||||
|
tagDescription: tag.tagDescription || '',
|
||||||
|
isDashboardTag: tag.isDashboardTag,
|
||||||
|
existingImageUrl: tag.imageUrl || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<TagForm
|
||||||
|
mode="edit"
|
||||||
|
initialValues={initialValues}
|
||||||
|
existingImageUrl={tag.imageUrl || undefined}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/manage-orders/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Manage Orders' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
apps/admin-ui/app/(drawer)/manage-orders/index.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||||
|
import { MyText, BottomDropdown, tw, MyFlatList, useMarkDataFetchers } from 'common-ui';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
|
export default function ManageOrders() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null);
|
||||||
|
const { data: slotsData, refetch } = trpc.admin.slots.getAll.useQuery();
|
||||||
|
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create slot options with Flash Deliveries as first option
|
||||||
|
const slotOptions = [
|
||||||
|
{ label: '⚡ Flash Deliveries', value: 'flash' },
|
||||||
|
...(slotsData?.slots?.map(slot => ({
|
||||||
|
label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'),
|
||||||
|
value: slot.id.toString()
|
||||||
|
})) || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
title: 'Delivery Sequences',
|
||||||
|
icon: 'route',
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
onPress: () => {
|
||||||
|
if (selectedSlotId === 'flash') {
|
||||||
|
Alert.alert('Flash Deliveries', 'Flash deliveries do not have delivery sequences. Use the Orders menu to manage flash deliveries.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/(drawer)/delivery-sequences?slotId=${selectedSlotId}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Orders',
|
||||||
|
icon: 'list',
|
||||||
|
color: 'bg-cyan-500',
|
||||||
|
onPress: () => {
|
||||||
|
if (selectedSlotId === 'flash') {
|
||||||
|
router.push('/(drawer)/orders?filter=flash');
|
||||||
|
} else {
|
||||||
|
router.push('/(drawer)/orders');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyFlatList
|
||||||
|
data={menuItems}
|
||||||
|
numColumns={2}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`${item.color} p-6 rounded-2xl shadow-lg mb-4 flex-1`}
|
||||||
|
onPress={item.onPress}
|
||||||
|
>
|
||||||
|
<View style={tw`items-center`}>
|
||||||
|
<MaterialIcons name={item.icon as any} size={32} color="white" style={tw`mb-2`} />
|
||||||
|
<MyText style={tw`text-white text-lg font-bold text-center`}>
|
||||||
|
{item.title}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item, index) => index.toString()}
|
||||||
|
columnWrapperStyle={tw`justify-between gap-4`}
|
||||||
|
contentContainerStyle={tw`p-6 gap-4 bg-white flex-1`}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
<View style={tw`mb-6`}>
|
||||||
|
<BottomDropdown
|
||||||
|
label='Select Slot'
|
||||||
|
options={slotOptions}
|
||||||
|
value={selectedSlotId || ''}
|
||||||
|
onValueChange={val => setSelectedSlotId(String(val))}
|
||||||
|
placeholder="Select Slot"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
886
apps/admin-ui/app/(drawer)/order-details/[id].tsx
Normal file
|
|
@ -0,0 +1,886 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
Platform,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { AppContainer, MyText, tw, BottomDialog, MyTextInput, theme } from "common-ui";
|
||||||
|
import { trpc } from "@/src/trpc-client";
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import CancelOrderDialog from "@/components/CancelOrderDialog";
|
||||||
|
|
||||||
|
export default function OrderDetails() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [generateCouponDialogOpen, setGenerateCouponDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [initiateRefundDialogOpen, setInitiateRefundDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
|
const [refundType, setRefundType] = useState<"percent" | "amount">("percent");
|
||||||
|
const [refundValue, setRefundValue] = useState("100");
|
||||||
|
const [updatingItems, setUpdatingItems] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: orderData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = trpc.admin.order.getOrderDetails.useQuery(
|
||||||
|
{ orderId: id ? parseInt(id) : 0 },
|
||||||
|
{ enabled: !!id }
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateCouponMutation =
|
||||||
|
trpc.admin.coupon.generateCancellationCoupon.useMutation({
|
||||||
|
onSuccess: (coupon) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Success",
|
||||||
|
`Refund coupon generated successfully!\n\nCode: ${coupon.couponCode
|
||||||
|
}\nValue: ₹${coupon.flatDiscount}\nExpires: ${coupon.validTill
|
||||||
|
? new Date(coupon.validTill).toLocaleDateString()
|
||||||
|
: "N/A"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
setGenerateCouponDialogOpen(false);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
error.message || "Failed to generate refund coupon"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initiateRefundMutation = trpc.admin.payments.initiateRefund.useMutation(
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Success",
|
||||||
|
`Refund initiated successfully!\n\nAmount: ₹${result.amount}\nStatus: ${result.status}`
|
||||||
|
);
|
||||||
|
setInitiateRefundDialogOpen(false);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert("Error", error.message || "Failed to initiate refund");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
// Refetch order details to get updated packaging status
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert("Error", error.message || "Failed to update packaging status");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
|
||||||
|
<MyText style={tw`text-gray-500 font-medium`}>
|
||||||
|
Loading order details...
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !orderData) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center p-6 bg-gray-50`}>
|
||||||
|
<View
|
||||||
|
style={tw`bg-white p-6 rounded-2xl shadow-sm items-center w-full`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="error-outline" size={48} color="#EF4444" />
|
||||||
|
<MyText style={tw`text-gray-900 text-xl font-bold mt-4 mb-2`}>
|
||||||
|
Oops!
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-center mb-6`}>
|
||||||
|
{error?.message || "Failed to load order details"}
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={tw`bg-gray-900 px-6 py-3 rounded-xl w-full items-center`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-bold`}>Go Back</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = orderData;
|
||||||
|
|
||||||
|
// Calculate subtotal and discount for order summary
|
||||||
|
const subtotal = order.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
const discountAmount = order.discountAmount || 0;
|
||||||
|
|
||||||
|
const handleGenerateCoupon = () => {
|
||||||
|
generateCouponMutation.mutate({ orderId: order.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInitiateRefund = () => {
|
||||||
|
const value = parseFloat(refundValue);
|
||||||
|
if (isNaN(value) || value <= 0) {
|
||||||
|
Alert.alert("Error", "Please enter a valid refund value");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutationData: any = {
|
||||||
|
orderId: order.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (refundType === "percent") {
|
||||||
|
if (value > 100) {
|
||||||
|
Alert.alert("Error", "Refund percentage cannot exceed 100%");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutationData.refundPercent = value;
|
||||||
|
} else {
|
||||||
|
mutationData.refundAmount = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
initiateRefundMutation.mutate(mutationData);
|
||||||
|
setInitiateRefundDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePackagingToggle = (itemId: number, field: 'isPackaged' | 'isPackageVerified', value: boolean) => {
|
||||||
|
// Add item to updating set to disable UI
|
||||||
|
setUpdatingItems(prev => new Set(prev).add(itemId));
|
||||||
|
|
||||||
|
updateItemPackagingMutation.mutate(
|
||||||
|
{
|
||||||
|
orderItemId: itemId,
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
// Remove item from updating set
|
||||||
|
setUpdatingItems(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(itemId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "delivered":
|
||||||
|
return "text-green-600 bg-green-50 border-green-100";
|
||||||
|
case "cancelled":
|
||||||
|
return "text-red-600 bg-red-50 border-red-100";
|
||||||
|
default:
|
||||||
|
return "text-yellow-600 bg-yellow-50 border-yellow-100";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusStyle = getStatusColor(order.status);
|
||||||
|
const showRefundOptions = true;
|
||||||
|
|
||||||
|
const getRefundDotColor = (status: string) => {
|
||||||
|
if (status === 'success') return 'bg-green-500';
|
||||||
|
else if (status === 'pending') return 'bg-yellow-500';
|
||||||
|
else if (status === 'failed') return 'bg-red-500';
|
||||||
|
else return 'bg-gray-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRefundTextColor = (status: string) => {
|
||||||
|
if (status === 'success' || status === 'na') return 'text-green-700';
|
||||||
|
else if (status === 'pending') return 'text-yellow-700';
|
||||||
|
else if (status === 'failed') return 'text-red-700';
|
||||||
|
else return 'text-gray-700';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRefundStatusText = (status: string) => {
|
||||||
|
if (status === 'success' || status === 'na') return 'Completed';
|
||||||
|
else if (status === 'pending') return 'Pending';
|
||||||
|
else if (status === 'failed') return 'Failed';
|
||||||
|
else if (status === 'none') return 'Not Initiated';
|
||||||
|
else if (status === 'na') return 'Not Applicable';
|
||||||
|
else return 'Unknown';
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={tw`flex-1`}
|
||||||
|
>
|
||||||
|
{/* Order ID & Status Card */}
|
||||||
|
<View
|
||||||
|
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row justify-between items-start mb-4`}>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-sm text-gray-500 mb-1`}>Order ID</MyText>
|
||||||
|
<MyText style={tw`text-2xl font-bold text-gray-900`}>
|
||||||
|
#{order.readableId}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center gap-2`}>
|
||||||
|
<View style={tw`px-3 py-1.5 rounded-full border ${statusStyle}`}>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-xs font-bold uppercase tracking-wider ${statusStyle.split(" ")[0]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{order.status}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View style={tw`px-2 py-1 bg-amber-100 rounded-full border border-amber-200`}>
|
||||||
|
<MyText style={tw`text-[10px] font-black text-amber-700 uppercase`}>⚡</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-center bg-gray-50 p-3 rounded-xl mb-2 ${order.isFlashDelivery ? 'bg-amber-50' : 'bg-brand50'}`}>
|
||||||
|
<MaterialIcons name="access-time" size={20} color={order.isFlashDelivery ? "#D97706" : theme.colors.brand800} />
|
||||||
|
<MyText style={tw`ml-2 font-medium ${order.isFlashDelivery ? 'text-amber-800' : 'text-brand800'}`}>
|
||||||
|
{order.isFlashDelivery ? "Flash Delivery:" : "Delivery at:"} {order.isFlashDelivery
|
||||||
|
? dayjs(order.createdAt).add(30, 'minutes').format("MMM DD, YYYY • h:mm A")
|
||||||
|
: order.slotInfo?.time ? dayjs(order.slotInfo.time).format("MMM DD, YYYY • h:mm A") : 'Not scheduled'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View style={tw`flex-row items-center bg-amber-50 p-3 rounded-xl border border-amber-200`}>
|
||||||
|
<MaterialIcons name="bolt" size={18} color="#D97706" />
|
||||||
|
<MyText style={tw`ml-2 text-amber-800 font-bold text-sm`}>
|
||||||
|
⚡ 30-Minute Flash Delivery • High Priority
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={tw`flex-row items-center bg-gray-50 p-3 rounded-xl`}>
|
||||||
|
<MaterialIcons name="access-time" size={20} color="#6B7280" />
|
||||||
|
<MyText style={tw`ml-2 text-gray-600 font-medium`}>
|
||||||
|
Placed on {dayjs(order.createdAt).format("MMM DD, YYYY • h:mm A")}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Order Progress (Simplified Timeline) */}
|
||||||
|
{order.status !== "cancelled" && (
|
||||||
|
<View
|
||||||
|
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-base font-bold text-gray-900 mb-4`}>
|
||||||
|
Order Status
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row justify-between items-center px-2`}>
|
||||||
|
{/* Placed */}
|
||||||
|
<View style={tw`items-center`}>
|
||||||
|
<View
|
||||||
|
style={tw`w-8 h-8 rounded-full bg-green-100 items-center justify-center mb-2`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="check" size={16} color="#10B981" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-xs font-medium text-gray-900`}>
|
||||||
|
Placed
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<View style={tw`h-0.5 flex-1 bg-green-200 mx-2 -mt-6`} />
|
||||||
|
|
||||||
|
{/* Packaged */}
|
||||||
|
<View style={tw`items-center`}>
|
||||||
|
<View
|
||||||
|
style={tw`w-8 h-8 rounded-full ${order.isPackaged ? "bg-green-100" : "bg-gray-100"
|
||||||
|
} items-center justify-center mb-2`}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="inventory-2"
|
||||||
|
size={16}
|
||||||
|
color={order.isPackaged ? "#10B981" : "#9CA3AF"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-xs font-medium ${order.isPackaged ? "text-gray-900" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Packaged
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={tw`h-0.5 flex-1 ${order.isPackaged ? "bg-green-200" : "bg-gray-100"
|
||||||
|
} mx-2 -mt-6`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delivered */}
|
||||||
|
<View style={tw`items-center`}>
|
||||||
|
<View
|
||||||
|
style={tw`w-8 h-8 rounded-full ${order.isDelivered ? "bg-green-100" : "bg-gray-100"
|
||||||
|
} items-center justify-center mb-2`}
|
||||||
|
>
|
||||||
|
{order.isFlashDelivery && order.isDelivered ? (
|
||||||
|
<MaterialIcons name="bolt" size={16} color="#D97706" />
|
||||||
|
) : (
|
||||||
|
<MaterialIcons
|
||||||
|
name="local-shipping"
|
||||||
|
size={16}
|
||||||
|
color={order.isDelivered ? "#10B981" : "#9CA3AF"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={tw`items-center`}>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-xs font-medium ${order.isDelivered ? "text-gray-900" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{order.isFlashDelivery && order.isDelivered ? "Flash Delivered" : "Delivered"}
|
||||||
|
</MyText>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<MyText style={tw`text-[9px] font-bold text-amber-600 mt-0.5`}>
|
||||||
|
⚡ 30-Min
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Customer Details */}
|
||||||
|
<View
|
||||||
|
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-base font-bold text-gray-900 mb-4`}>
|
||||||
|
Customer Details
|
||||||
|
</MyText>
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
|
<View
|
||||||
|
style={tw`w-10 h-10 bg-blue-50 rounded-full items-center justify-center mr-3`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="person" size={20} color="#3B82F6" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-sm font-bold text-gray-900`}>
|
||||||
|
{order.customerName}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-xs text-gray-500`}>Customer</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`space-y-3`}>
|
||||||
|
<View style={tw`flex-row items-start`}>
|
||||||
|
<View style={tw`w-6 mt-0.5`}>
|
||||||
|
<MaterialIcons name="phone" size={16} color="#6B7280" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-gray-600 flex-1`}>
|
||||||
|
{order.customerMobile}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
{order.customerEmail && (
|
||||||
|
<View style={tw`flex-row items-start mt-2`}>
|
||||||
|
<View style={tw`w-6 mt-0.5`}>
|
||||||
|
<MaterialIcons name="email" size={16} color="#6B7280" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-gray-600 flex-1`}>
|
||||||
|
{order.customerEmail}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={tw`flex-row items-start mt-2 pt-3 border-t border-gray-50`}
|
||||||
|
>
|
||||||
|
<View style={tw`w-6 mt-0.5`}>
|
||||||
|
<MaterialIcons name="location-on" size={16} color="#6B7280" />
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-600 leading-5`}>
|
||||||
|
{order.address.line1}
|
||||||
|
{order.address.line2 ? `, ${order.address.line2}` : ""}
|
||||||
|
{`\n${order.address.city}, ${order.address.state} - ${order.address.pincode}`}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Order Items */}
|
||||||
|
<View
|
||||||
|
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-base font-bold text-gray-900 mb-4`}>
|
||||||
|
Items Ordered
|
||||||
|
</MyText>
|
||||||
|
{order.items.map((item, index) => (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={tw`flex-row items-center py-3 border-b border-gray-50 ${index === order.items.length - 1 ? "border-b-0" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={tw`w-10 h-10 bg-gray-100 rounded-lg items-center justify-center mr-3`}
|
||||||
|
>
|
||||||
|
<FontAwesome5 name="box" size={14} color="#6B7280" />
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-sm font-bold text-gray-900`}>
|
||||||
|
{item.name}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-xs text-gray-500`}>
|
||||||
|
{item.quantity} {item.unit} × ₹{item.price}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row items-center mt-2 gap-3`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center`}
|
||||||
|
onPress={() => handlePackagingToggle(item.id, 'isPackaged', !item.isPackaged)}
|
||||||
|
disabled={updatingItems.has(item.id)}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={updatingItems.has(item.id) ? "hourglass-empty" : item.isPackaged ? "check-box" : "check-box-outline-blank"}
|
||||||
|
size={16}
|
||||||
|
color={updatingItems.has(item.id) ? "#F59E0B" : item.isPackaged ? "#10B981" : "#9CA3AF"}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`text-xs ml-1 ${updatingItems.has(item.id) ? "text-yellow-700" : item.isPackaged ? "text-green-700" : "text-gray-500"}`}>
|
||||||
|
Packaged
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center`}
|
||||||
|
onPress={() => handlePackagingToggle(item.id, 'isPackageVerified', !item.isPackageVerified)}
|
||||||
|
disabled={updatingItems.has(item.id)}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={updatingItems.has(item.id) ? "hourglass-empty" : item.isPackageVerified ? "check-box" : "check-box-outline-blank"}
|
||||||
|
size={16}
|
||||||
|
color={updatingItems.has(item.id) ? "#F59E0B" : item.isPackageVerified ? "#10B981" : "#9CA3AF"}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`text-xs ml-1 ${updatingItems.has(item.id) ? "text-yellow-700" : item.isPackageVerified ? "text-green-700" : "text-gray-500"}`}>
|
||||||
|
Pkg Verified
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm font-bold text-gray-900`}>
|
||||||
|
₹{item.amount}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={tw`mt-4 pt-4 border-t border-gray-100`}>
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-2`}>
|
||||||
|
<MyText style={tw`text-base font-bold text-gray-900`}>
|
||||||
|
Subtotal ({order.items.length} items)
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-base font-bold text-gray-900`}>
|
||||||
|
₹{subtotal}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
{discountAmount > 0 && (
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-2`}>
|
||||||
|
<MyText style={tw`text-emerald-600 font-medium`}>
|
||||||
|
Discount
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-emerald-600 font-medium`}>
|
||||||
|
-₹{discountAmount}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={tw`flex-row justify-between items-center pt-2 border-t border-gray-200`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MyText style={tw`text-lg font-bold ${order.isFlashDelivery ? 'text-amber-900' : 'text-gray-900'}`}>
|
||||||
|
Total Amount
|
||||||
|
</MyText>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View style={tw`ml-2 px-2 py-0.5 bg-amber-100 rounded-full border border-amber-200`}>
|
||||||
|
<MyText style={tw`text-[8px] font-black text-amber-700 uppercase`}>⚡ FLASH</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-xl font-bold ${order.isFlashDelivery ? 'text-amber-700' : 'text-blue-600'}`}>
|
||||||
|
₹{order.totalAmount}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Flash Delivery Priority Notice */}
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View
|
||||||
|
style={tw`bg-gradient-to-r from-amber-50 to-yellow-50 p-5 rounded-2xl border border-amber-200 mb-4`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<MaterialIcons name="bolt" size={24} color="#D97706" />
|
||||||
|
<MyText style={tw`text-amber-900 font-bold text-lg ml-3`}>
|
||||||
|
Flash Delivery Order
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-amber-800 text-sm leading-5`}>
|
||||||
|
⚡ This is a high-priority flash delivery order that must be delivered within 30 minutes of placement.
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`mt-3 p-3 bg-amber-100 rounded-lg border border-amber-300`}>
|
||||||
|
<MyText style={tw`text-amber-900 font-semibold text-sm`}>
|
||||||
|
Expected Delivery: {dayjs(order.createdAt).add(30, 'minutes').format("MMM DD, YYYY • h:mm A")}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin Notes */}
|
||||||
|
{order.adminNotes && (
|
||||||
|
<View
|
||||||
|
style={tw`bg-yellow-50 p-5 rounded-2xl border border-yellow-100 mb-4`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<MaterialIcons name="note" size={18} color="#D97706" />
|
||||||
|
<MyText style={tw`text-sm font-bold text-yellow-800 ml-2`}>
|
||||||
|
Admin Notes
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-yellow-900 leading-5`}>
|
||||||
|
{order.adminNotes}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coupon Applied Section */}
|
||||||
|
{order.couponCode && (
|
||||||
|
<View
|
||||||
|
style={tw`bg-emerald-50 p-5 rounded-2xl shadow-sm mb-4 border border-emerald-100`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-lg font-bold text-emerald-800 mb-3`}>
|
||||||
|
Coupon Applied
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`bg-emerald-100 p-4 rounded-xl border border-emerald-200`}>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<MaterialIcons name="local-offer" size={20} color="#10B981" />
|
||||||
|
<MyText style={tw`text-emerald-800 font-bold ml-2`}>
|
||||||
|
{order.couponCode}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-emerald-700 text-sm`}>
|
||||||
|
{order.couponDescription}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row justify-between items-center mt-3`}>
|
||||||
|
<MyText style={tw`text-emerald-600 font-medium`}>
|
||||||
|
Discount Applied:
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-emerald-800 font-bold text-lg`}>
|
||||||
|
-₹{order.discountAmount}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Refund Coupon Section */}
|
||||||
|
{order.orderStatus?.refundCouponId && (
|
||||||
|
<View style={tw`bg-blue-50 p-5 rounded-2xl shadow-sm mb-4 border border-blue-100`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-blue-800 mb-3`}>
|
||||||
|
Refund Coupon
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`bg-blue-100 p-4 rounded-xl border border-blue-200`}>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<MaterialIcons name="local-offer" size={20} color="#2563EB" />
|
||||||
|
<MyText style={tw`text-blue-800 font-bold ml-2`}>
|
||||||
|
{order.couponCode}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-blue-700 text-sm`}>
|
||||||
|
Generated refund coupon for order cancellation
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row justify-between items-center mt-3`}>
|
||||||
|
<MyText style={tw`text-blue-600 font-medium`}>
|
||||||
|
Value:
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-blue-800 font-bold text-lg`}>
|
||||||
|
₹{order.couponData?.discountAmount}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
{/* <View style={tw`flex-row justify-between items-center mt-2`}>
|
||||||
|
<MyText style={tw`text-blue-600 font-medium`}>
|
||||||
|
Expires:
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-blue-800 font-medium`}>
|
||||||
|
{order.couponData?.
|
||||||
|
? dayjs(order.orderStatus.refundCoupon.validTill).format("DD MMM YYYY")
|
||||||
|
: "N/A"}
|
||||||
|
</MyText>
|
||||||
|
</View> */}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TEMPORARILY HIDDEN: Refund Details Section */}
|
||||||
|
{/* WARNING: This section contains functional refund and cancellation management features */}
|
||||||
|
{/* DO NOT REMOVE - This is temporarily commented out for UI simplification */}
|
||||||
|
{/* When ready to re-enable, simply uncomment the entire block below */}
|
||||||
|
{/*
|
||||||
|
<View
|
||||||
|
style={tw`bg-red-50 p-5 rounded-2xl border border-red-100 mb-4`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-sm font-bold text-red-800 mb-3`}>
|
||||||
|
{order.status === "cancelled" ? "Cancellation Details" : "Refund Details"}
|
||||||
|
</MyText>
|
||||||
|
{order.status === "cancelled" && order.cancelReason && (
|
||||||
|
<View style={tw`mb-3`}>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-xs text-red-600 uppercase font-bold mb-1`}
|
||||||
|
>
|
||||||
|
Cancellation Reason
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-sm text-red-900`}>
|
||||||
|
{order.cancelReason}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={tw`flex-row justify-between items-center bg-white/50 p-3 rounded-xl mb-4`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-sm font-medium text-red-800`}>
|
||||||
|
Refund Status
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<View
|
||||||
|
style={tw`w-2 h-2 rounded-full mr-2 ${getRefundDotColor(order.refundStatus)}`}
|
||||||
|
/>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-sm font-bold ${getRefundTextColor(order.refundStatus)}`}
|
||||||
|
>
|
||||||
|
{getRefundStatusText(order.refundStatus)}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{order.refundRecord && (
|
||||||
|
<View
|
||||||
|
style={tw`flex-row justify-between items-center bg-white/50 p-3 rounded-xl mb-4`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-sm font-medium text-red-800`}>
|
||||||
|
Refund Amount
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-sm font-bold text-red-900`}>
|
||||||
|
₹{order.refundRecord.refundAmount}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!Boolean(order.refundRecord)) && <View style={tw`flex-row gap-3 mt-2`}>
|
||||||
|
{!order.isCod && (<TouchableOpacity
|
||||||
|
style={tw`flex-1 bg-red-500 py-3 px-4 rounded-xl items-center flex-row justify-center shadow-sm`}
|
||||||
|
onPress={() => setInitiateRefundDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="payments" size={18} color="white" style={tw`mr-2`} />
|
||||||
|
<MyText style={tw`text-white font-bold text-sm`}>
|
||||||
|
Initiate Refund
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 bg-emerald-500 py-3 px-4 rounded-xl items-center flex-row justify-center shadow-sm`}
|
||||||
|
onPress={() => setGenerateCouponDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="local-offer" size={18} color="white" style={tw`mr-2`} />
|
||||||
|
<MyText style={tw`text-white font-bold text-sm`}>
|
||||||
|
Generate Refund Coupon
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>}
|
||||||
|
|
||||||
|
</View>
|
||||||
|
*/}
|
||||||
|
</View>
|
||||||
|
<View style={tw`h-32`}></View>
|
||||||
|
|
||||||
|
{/* Bottom Action Bar */}
|
||||||
|
<View
|
||||||
|
style={tw`absolute bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-4 pb-${Platform.OS === "ios" ? "8" : "4"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{order.status !== "cancelled" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setCancelDialogOpen(true)}
|
||||||
|
style={tw`bg-red-500 rounded-xl py-4 items-center shadow-lg mb-3`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-bold text-base`}>
|
||||||
|
Cancel Order
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push("/(drawer)/manage-orders")}
|
||||||
|
style={tw`bg-gray-900 rounded-xl py-4 items-center shadow-lg`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-bold text-base`}>
|
||||||
|
Manage Orders
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Generate Coupon Dialog */}
|
||||||
|
<BottomDialog
|
||||||
|
open={generateCouponDialogOpen}
|
||||||
|
onClose={() => setGenerateCouponDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<View style={tw`p-6`}>
|
||||||
|
<View style={tw`items-center mb-6`}>
|
||||||
|
<View style={tw`w-12 h-12 bg-emerald-100 rounded-full items-center justify-center mb-3`}>
|
||||||
|
<MaterialIcons name="local-offer" size={24} color="#10B981" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900 text-center`}>
|
||||||
|
Generate Refund Coupon
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-center mt-2 text-sm leading-5`}>
|
||||||
|
Create a one-time use coupon for the customer equal to the order amount. Valid for 30 days.
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 flex-row items-start`}>
|
||||||
|
<MaterialIcons name="info-outline" size={20} color="#D97706" style={tw`mt-0.5`} />
|
||||||
|
<MyText style={tw`text-sm text-amber-800 ml-2 flex-1 leading-5`}>
|
||||||
|
This only works for online payment orders. COD orders cannot generate refund coupons.
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row gap-3`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 bg-gray-100 py-3.5 rounded-xl items-center`}
|
||||||
|
onPress={() => setGenerateCouponDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-gray-700 font-bold`}>Cancel</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 bg-emerald-500 py-3.5 rounded-xl items-center shadow-sm`}
|
||||||
|
onPress={handleGenerateCoupon}
|
||||||
|
disabled={generateCouponMutation.isPending}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-bold`}>
|
||||||
|
{generateCouponMutation.isPending
|
||||||
|
? "Generating..."
|
||||||
|
: "Generate Coupon"}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
|
||||||
|
{/* Initiate Refund Dialog */}
|
||||||
|
<BottomDialog
|
||||||
|
open={initiateRefundDialogOpen}
|
||||||
|
onClose={() => setInitiateRefundDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<View style={tw`p-6`}>
|
||||||
|
<View style={tw`items-center mb-6`}>
|
||||||
|
<View style={tw`w-12 h-12 bg-red-100 rounded-full items-center justify-center mb-3`}>
|
||||||
|
<MaterialIcons name="payments" size={24} color="#EF4444" />
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900 text-center`}>
|
||||||
|
Initiate Refund
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-500 text-center mt-2 text-sm`}>
|
||||||
|
Process a refund directly to the customer's source account via Razorpay.
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Refund Type Selection */}
|
||||||
|
<View style={tw`mb-6`}>
|
||||||
|
<MyText style={tw`text-sm font-bold text-gray-900 mb-3 uppercase tracking-wide`}>
|
||||||
|
Refund Type
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row gap-3`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 p-4 rounded-xl border-2 ${refundType === "percent"
|
||||||
|
? "border-blue-500 bg-blue-50"
|
||||||
|
: "border-gray-100 bg-gray-50"
|
||||||
|
} items-center`}
|
||||||
|
onPress={() => setRefundType("percent")}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="percent"
|
||||||
|
size={24}
|
||||||
|
color={refundType === "percent" ? "#3B82F6" : "#9CA3AF"}
|
||||||
|
style={tw`mb-2`}
|
||||||
|
/>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-sm font-bold ${refundType === "percent" ? "text-blue-700" : "text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Percentage
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 p-4 rounded-xl border-2 ${refundType === "amount"
|
||||||
|
? "border-blue-500 bg-blue-50"
|
||||||
|
: "border-gray-100 bg-gray-50"
|
||||||
|
} items-center`}
|
||||||
|
onPress={() => setRefundType("amount")}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="attach-money"
|
||||||
|
size={24}
|
||||||
|
color={refundType === "amount" ? "#3B82F6" : "#9CA3AF"}
|
||||||
|
style={tw`mb-2`}
|
||||||
|
/>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-sm font-bold ${refundType === "amount" ? "text-blue-700" : "text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Fixed Amount
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Refund Value Input */}
|
||||||
|
<View style={tw`mb-6`}>
|
||||||
|
<MyTextInput
|
||||||
|
topLabel={`Refund ${refundType === "percent" ? "Percentage (%)" : "Amount (₹)"
|
||||||
|
}`}
|
||||||
|
value={refundValue}
|
||||||
|
onChangeText={setRefundValue}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder={refundType === "percent" ? "100" : "0.00"}
|
||||||
|
style={tw`bg-white`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 flex-row items-start`}>
|
||||||
|
<MaterialIcons name="info-outline" size={20} color="#D97706" style={tw`mt-0.5`} />
|
||||||
|
<MyText style={tw`text-sm text-amber-800 ml-2 flex-1 leading-5`}>
|
||||||
|
For COD orders, refunds are processed immediately upon delivery confirmation.
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row gap-3`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 bg-gray-100 py-3.5 rounded-xl items-center`}
|
||||||
|
onPress={() => setInitiateRefundDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-gray-700 font-bold`}>Cancel</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 bg-red-500 py-3.5 rounded-xl items-center shadow-sm`}
|
||||||
|
onPress={handleInitiateRefund}
|
||||||
|
disabled={initiateRefundMutation.isPending}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-bold`}>
|
||||||
|
{initiateRefundMutation.isPending
|
||||||
|
? "Processing..."
|
||||||
|
: "Confirm Refund"}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
|
||||||
|
{/* Cancel Order Dialog */}
|
||||||
|
<CancelOrderDialog
|
||||||
|
orderId={order.id}
|
||||||
|
open={cancelDialogOpen}
|
||||||
|
onClose={() => setCancelDialogOpen(false)}
|
||||||
|
onSuccess={refetch}
|
||||||
|
/>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
15
apps/admin-ui/app/(drawer)/order-details/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="[id]"
|
||||||
|
options={{
|
||||||
|
title: "Order Details",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/orders/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Orders' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
836
apps/admin-ui/app/(drawer)/orders/index.tsx
Normal file
|
|
@ -0,0 +1,836 @@
|
||||||
|
import React, { useState , useEffect } from 'react';
|
||||||
|
import { View, TouchableOpacity, Alert, TextInput, ActivityIndicator } from 'react-native';
|
||||||
|
import { AppContainer, MyText, tw, MyFlatList, BottomDialog, BottomDropdown, Checkbox, theme, MyTextInput } from 'common-ui';
|
||||||
|
import { trpc } from '../../../src/trpc-client';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { Entypo } from '@expo/vector-icons';
|
||||||
|
import CancelOrderDialog from '@/components/CancelOrderDialog';
|
||||||
|
|
||||||
|
const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string; existingNotes?: string | null; onClose: () => void; refetch: () => void }) => {
|
||||||
|
const [notesText, setNotesText] = useState(existingNotes || '');
|
||||||
|
const updateNotesMutation = trpc.admin.order.updateNotes.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`p-4`}>
|
||||||
|
<MyText style={tw`text-lg font-bold mb-4`}>Admin Notes</MyText>
|
||||||
|
<TextInput
|
||||||
|
style={tw`border border-gray-300 rounded p-3 h-24 text-base`}
|
||||||
|
multiline
|
||||||
|
value={notesText}
|
||||||
|
onChangeText={setNotesText}
|
||||||
|
placeholder="Enter admin notes..."
|
||||||
|
/>
|
||||||
|
<View style={tw`flex-row mt-4`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-1 bg-blue-500 p-3 rounded ml-2`}
|
||||||
|
onPress={() => {
|
||||||
|
updateNotesMutation.mutate(
|
||||||
|
{ orderId: parseInt(orderId), adminNotes: notesText },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose();
|
||||||
|
Alert.alert('Success', 'Notes updated successfully');
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to update notes');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-center text-white font-semibold`}>Save</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
interface OrderType {
|
||||||
|
id: number;
|
||||||
|
orderId: string;
|
||||||
|
readableId: number;
|
||||||
|
customerName: string | null;
|
||||||
|
address: string;
|
||||||
|
totalAmount: number;
|
||||||
|
deliveryCharge: number;
|
||||||
|
items: {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
amount: number;
|
||||||
|
unit: string;
|
||||||
|
isPackaged?: boolean;
|
||||||
|
isPackageVerified?: boolean;
|
||||||
|
}[];
|
||||||
|
createdAt: string;
|
||||||
|
deliveryTime: string | null;
|
||||||
|
status: 'pending' | 'delivered' | 'cancelled';
|
||||||
|
isPackaged: boolean;
|
||||||
|
isDelivered: boolean;
|
||||||
|
isCod: boolean;
|
||||||
|
isFlashDelivery: boolean;
|
||||||
|
couponCode?: string;
|
||||||
|
couponDescription?: string;
|
||||||
|
discountAmount?: number;
|
||||||
|
adminNotes?: string | null;
|
||||||
|
userNotes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
|
||||||
|
const id = order.orderId;
|
||||||
|
const router = useRouter();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [itemsDialogOpen, setItemsDialogOpen] = useState(false);
|
||||||
|
const [notesDialogOpen, setNotesDialogOpen] = useState(false);
|
||||||
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
|
const [userNotesDialogOpen, setUserNotesDialogOpen] = useState(false);
|
||||||
|
const [adminNotesDialogOpen, setAdminNotesDialogOpen] = useState(false);
|
||||||
|
const [updatingItems, setUpdatingItems] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const updatePackagedMutation = trpc.admin.order.updatePackaged.useMutation();
|
||||||
|
const updateDeliveredMutation = trpc.admin.order.updateDelivered.useMutation();
|
||||||
|
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation();
|
||||||
|
|
||||||
|
const handleOrderPress = () => {
|
||||||
|
router.push(`/order-details/${order.orderId}` as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuOption = () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
router.push(`/order-details/${order.orderId}` as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkPackaged = (isPackaged: boolean) => {
|
||||||
|
updatePackagedMutation.mutate(
|
||||||
|
{ orderId: order.orderId.toString(), isPackaged },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkDelivered = (isDelivered: boolean) => {
|
||||||
|
updateDeliveredMutation.mutate(
|
||||||
|
{ orderId: order.orderId.toString(), isDelivered },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemPackagingToggle = (itemId: number, field: 'isPackaged' | 'isPackageVerified', value: boolean) => {
|
||||||
|
setUpdatingItems(prev => new Set(prev).add(itemId));
|
||||||
|
|
||||||
|
updateItemPackagingMutation.mutate(
|
||||||
|
{ orderItemId: itemId, [field]: value },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setUpdatingItems(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(itemId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setUpdatingItems(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(itemId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
Alert.alert("Error", error.message || "Failed to update packaging status");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'delivered': return 'bg-green-100 text-green-800';
|
||||||
|
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||||
|
default: return 'bg-yellow-100 text-yellow-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if(order.id === 162)
|
||||||
|
console.log({order})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`bg-white mx-4 mb-4 rounded-xl shadow-sm border border-gray-100 overflow-hidden`}
|
||||||
|
onPress={handleOrderPress}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{/* Header Section */}
|
||||||
|
<View style={tw`p-4 border-b border-gray-100 bg-gray-50/50`}>
|
||||||
|
<View style={tw`flex-row justify-between items-start`}>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<View style={tw`flex-row items-center mb-1`}>
|
||||||
|
<MyText style={tw`font-bold text-lg text-gray-900 mr-2`}>
|
||||||
|
{order.customerName || 'Unknown Customer'}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`bg-gray-200 px-2 py-0.5 rounded mr-2`}>
|
||||||
|
<MyText style={tw`text-xs font-medium text-gray-600`}>#{order.readableId}</MyText>
|
||||||
|
</View>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View style={tw`bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200 flex-row items-center`}>
|
||||||
|
<MaterialIcons name="bolt" size={12} color="#D97706" />
|
||||||
|
<MyText style={tw`text-xs font-bold text-amber-700 ml-1`}>FLASH</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MaterialIcons name="access-time" size={12} color="#6B7280" />
|
||||||
|
<MyText style={tw`text-xs text-gray-500 ml-1`}>
|
||||||
|
{dayjs(order.createdAt).format('MMM D, h:mm A')}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setMenuOpen(true)}
|
||||||
|
style={tw`p-2 -mr-2 -mt-2 rounded-full`}
|
||||||
|
>
|
||||||
|
<Entypo name="dots-three-vertical" size={16} color="#9CA3AF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<View style={tw`p-4`}>
|
||||||
|
{/* Status Badges */}
|
||||||
|
<View style={tw`flex-row flex-wrap gap-4 mb-4`}>
|
||||||
|
{/* <View style={tw`px-2.5 py-1 rounded-full ${getStatusColor(order.status)}`}>
|
||||||
|
<MyText style={tw`text-xs font-semibold capitalize`}>{order.status}</MyText>
|
||||||
|
</View> */}
|
||||||
|
{/* {order.isCod && (
|
||||||
|
<View style={tw`px-2.5 py-1 rounded-full bg-blue-50 border border-blue-100`}>
|
||||||
|
<MyText style={tw`text-xs font-semibold text-blue-700`}>COD</MyText>
|
||||||
|
</View>
|
||||||
|
)} */}
|
||||||
|
<View style={tw`flex-row items-center gap-1`}>
|
||||||
|
<MyText style={tw`text-sm font-semibold text-gray-600`}>Packaged</MyText>
|
||||||
|
<Checkbox
|
||||||
|
checked={order.isPackaged}
|
||||||
|
// onPress={() => handleMarkPackaged(!order.isPackaged)}
|
||||||
|
onPress={() => {}}
|
||||||
|
size={18}
|
||||||
|
fillColor={theme.colors.gray500}
|
||||||
|
checkColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center gap-1`}>
|
||||||
|
<MyText style={tw`text-xs font-semibold text-gray-600`}>Delivered</MyText>
|
||||||
|
<Checkbox
|
||||||
|
checked={order.isDelivered}
|
||||||
|
onPress={() => handleMarkDelivered(!order.isDelivered)}
|
||||||
|
size={18}
|
||||||
|
fillColor="#10B981"
|
||||||
|
checkColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Delivery Info */}
|
||||||
|
<View style={tw`flex-row items-start mb-4 bg-blue-50/50 p-3 rounded-lg`}>
|
||||||
|
<MaterialIcons name="location-pin" size={18} color="#3B82F6" style={tw`mt-0.5`} />
|
||||||
|
<View style={tw`ml-2 flex-1`}>
|
||||||
|
<MyText style={tw`text-xs font-bold text-blue-800 mb-0.5 uppercase tracking-wide`}>Delivery Address</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-700 leading-5`} numberOfLines={2}>
|
||||||
|
{order.address}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row items-center mt-2`}>
|
||||||
|
<MaterialIcons name="event" size={14} color="#6B7280" />
|
||||||
|
<MyText style={tw`text-xs text-gray-600 ml-1`}>
|
||||||
|
{order.isFlashDelivery ? "Flash Delivery:" : "Slot:"} {order.isFlashDelivery ? dayjs(order.createdAt).add(30, 'minutes').format('MMM D, h:mm A') : order.deliveryTime ? dayjs(order.deliveryTime).format("ddd, MMM D • h:mm A") : 'Not scheduled'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View style={tw`flex-row items-center mt-1 bg-amber-50 px-2 py-1 rounded`}>
|
||||||
|
<MaterialIcons name="bolt" size={12} color="#D97706" />
|
||||||
|
<MyText style={tw`text-xs text-amber-700 ml-1 font-medium`}>
|
||||||
|
30-Minute Delivery • High Priority
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Items Summary & Total */}
|
||||||
|
<View style={tw`mb-4`}>
|
||||||
|
<View style={tw`mb-2`}>
|
||||||
|
<View style={tw`flex-row justify-between items-center`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setItemsDialogOpen(true)}
|
||||||
|
style={tw`flex-row items-center py-2 px-3 bg-blue-50 rounded-lg flex-1 mr-3`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="shopping-cart" size={16} color="#3B82F6" />
|
||||||
|
<MyText style={tw`text-sm font-medium text-blue-700 ml-2`}>
|
||||||
|
{order.items.length} {order.items.length === 1 ? 'item' : 'items'}
|
||||||
|
</MyText>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View style={tw`ml-2 bg-amber-100 px-1.5 py-0.5 rounded-full`}>
|
||||||
|
<MyText style={tw`text-xs font-bold text-amber-700`}>⚡</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MyText style={tw`text-base font-semibold text-gray-900 mr-2`}>Total:</MyText>
|
||||||
|
<MyText style={tw`text-lg font-bold ${order.isFlashDelivery ? 'text-amber-700' : 'text-gray-900'}`}>₹{order.totalAmount}</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Coupons */}
|
||||||
|
{order.couponCode && (
|
||||||
|
<View style={tw`mb-4`}>
|
||||||
|
<MyText style={tw`text-xs font-bold text-gray-500 mb-2 uppercase tracking-wide`}>Applied Coupons</MyText>
|
||||||
|
<View style={tw`bg-pink-50 border border-pink-200 rounded-lg p-3`}>
|
||||||
|
<MyText style={tw`text-sm text-pink-800 font-medium mb-1`}>
|
||||||
|
{order.couponCode}
|
||||||
|
</MyText>
|
||||||
|
{order.couponDescription && (
|
||||||
|
<MyText style={tw`text-xs text-pink-600 mb-2`}>
|
||||||
|
{order.couponDescription}
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
{order.discountAmount && (
|
||||||
|
<MyText style={tw`text-sm font-bold text-pink-800`}>
|
||||||
|
Discount: ₹{order.discountAmount}
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes Section */}
|
||||||
|
<View style={tw`flex-row gap-2`}>
|
||||||
|
{order.userNotes && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-3 bg-amber-50 rounded-lg flex-1`}
|
||||||
|
onPress={() => setUserNotesDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="note" size={18} color="#D97706" />
|
||||||
|
<MyText style={tw`text-amber-800 font-medium ml-2`}>
|
||||||
|
User Notes
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{order.adminNotes && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-3 bg-blue-50 rounded-lg flex-1`}
|
||||||
|
onPress={() => setNotesDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="admin-panel-settings" size={18} color="#2563EB" />
|
||||||
|
<MyText style={tw`text-blue-800 font-medium ml-2`}>
|
||||||
|
Admin Notes
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer / Delivery Charge */}
|
||||||
|
{order.deliveryCharge > 0 && (
|
||||||
|
<View style={tw`pt-3 border-t border-gray-100`}>
|
||||||
|
<View style={tw`flex-row justify-between items-center`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-500`}>Delivery Charge</MyText>
|
||||||
|
<MyText style={tw`text-sm text-gray-900`}>₹{order.deliveryCharge}</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
|
||||||
|
<View style={tw`p-6`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800 mb-4`}>
|
||||||
|
Order Options
|
||||||
|
</MyText>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View style={tw`bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4 flex-row items-center`}>
|
||||||
|
<MaterialIcons name="bolt" size={20} color="#D97706" />
|
||||||
|
<View style={tw`ml-3 flex-1`}>
|
||||||
|
<MyText style={tw`text-sm font-bold text-amber-900`}>Flash Delivery Order</MyText>
|
||||||
|
<MyText style={tw`text-xs text-amber-700`}>
|
||||||
|
Deliver within 30 minutes • High Priority
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg mb-3`}
|
||||||
|
onPress={() => handleMarkPackaged(!order.isPackaged)}
|
||||||
|
>
|
||||||
|
<Entypo name="box" size={20} color="#6B7280" />
|
||||||
|
<MyText style={tw`text-gray-800 font-medium ml-3`}>
|
||||||
|
{order.isPackaged ? 'Mark Not Packaged' : 'Mark Packaged'}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{order.isPackaged && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg mb-3`}
|
||||||
|
onPress={() => handleMarkDelivered(!order.isDelivered)}
|
||||||
|
>
|
||||||
|
<Entypo name="location" size={20} color="#6B7280" />
|
||||||
|
<MyText style={tw`text-gray-800 font-medium ml-3`}>
|
||||||
|
{order.isDelivered ? 'Mark Not Delivered' : 'Mark Delivered'}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg mb-3`}
|
||||||
|
onPress={handleMenuOption}
|
||||||
|
>
|
||||||
|
<Entypo name="info-with-circle" size={20} color="#6B7280" />
|
||||||
|
<MyText style={tw`text-gray-800 font-medium ml-3`}>
|
||||||
|
Order Details
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg mb-3`}
|
||||||
|
onPress={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setNotesDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Entypo name="edit" size={20} color="#6B7280" />
|
||||||
|
<MyText style={tw`text-gray-800 font-medium ml-3`}>
|
||||||
|
Admin Notes
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{order.status !== 'cancelled' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-red-50 rounded-lg`}
|
||||||
|
onPress={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setCancelDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="cancel" size={20} color="#DC2626" />
|
||||||
|
<MyText style={tw`text-red-700 font-medium ml-3`}>
|
||||||
|
Cancel Order
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
|
||||||
|
<BottomDialog open={itemsDialogOpen} onClose={() => setItemsDialogOpen(false)}>
|
||||||
|
<View style={tw`py-6`}>
|
||||||
|
<View style={tw`flex-row items-center justify-between mb-4`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800`}>
|
||||||
|
Order Items
|
||||||
|
</MyText>
|
||||||
|
{order.isFlashDelivery && (
|
||||||
|
<View style={tw`bg-amber-100 px-2 py-1 rounded-full border border-amber-200 flex-row items-center`}>
|
||||||
|
<MaterialIcons name="bolt" size={14} color="#D97706" />
|
||||||
|
<MyText style={tw`text-xs font-bold text-amber-700 ml-1`}>FLASH</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-gray-600 mb-6`}>
|
||||||
|
Total: ₹{order.totalAmount}
|
||||||
|
</MyText>
|
||||||
|
{order.items.map((item, idx) => (
|
||||||
|
<View key={idx} style={tw`py-2 border-b border-gray-50 last:border-0`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<View style={tw`bg-gray-100 px-2 py-1 rounded items-center justify-center mr-2`}>
|
||||||
|
<MyText style={tw`text-xs font-bold text-gray-600`}>{item.quantity} {item.unit}</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm text-gray-800 flex-1`} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name}
|
||||||
|
</MyText>
|
||||||
|
{item.isPackaged !== undefined && item.isPackageVerified !== undefined && (
|
||||||
|
<>
|
||||||
|
<View style={tw`flex-row items-center gap-1 mr-3`}>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-600`}>pkg</MyText>
|
||||||
|
<Checkbox
|
||||||
|
checked={item.isPackaged}
|
||||||
|
onPress={() => handleItemPackagingToggle(item.id!, 'isPackaged', !item.isPackaged)}
|
||||||
|
size={18}
|
||||||
|
fillColor={updatingItems.has(item.id!) ? "#F59E0B" : "#10B981"}
|
||||||
|
checkColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center gap-1`}>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-600`}>verf</MyText>
|
||||||
|
<Checkbox
|
||||||
|
checked={item.isPackageVerified}
|
||||||
|
onPress={() => handleItemPackagingToggle(item.id!, 'isPackageVerified', !item.isPackageVerified)}
|
||||||
|
size={18}
|
||||||
|
fillColor={updatingItems.has(item.id!) ? "#F59E0B" : "#10B981"}
|
||||||
|
checkColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{updatingItems.has(item.id!) && (
|
||||||
|
<ActivityIndicator size="small" color="#F59E0B" style={tw`ml-1`} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
|
||||||
|
<BottomDialog open={notesDialogOpen} onClose={() => setNotesDialogOpen(false)}>
|
||||||
|
<AdminNotesForm orderId={order.orderId} existingNotes={order.adminNotes} onClose={() => setNotesDialogOpen(false)} refetch={refetch} />
|
||||||
|
</BottomDialog>
|
||||||
|
|
||||||
|
<CancelOrderDialog
|
||||||
|
orderId={order.id}
|
||||||
|
open={cancelDialogOpen}
|
||||||
|
onClose={() => setCancelDialogOpen(false)}
|
||||||
|
onSuccess={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BottomDialog open={userNotesDialogOpen} onClose={() => setUserNotesDialogOpen(false)}>
|
||||||
|
<View style={tw`p-6`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800 mb-4`}>
|
||||||
|
User Notes
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`bg-amber-50 p-4 rounded-lg border border-amber-200`}>
|
||||||
|
<MyText style={tw`text-sm text-amber-900 leading-5`}>
|
||||||
|
{order.userNotes}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
|
||||||
|
<BottomDialog open={adminNotesDialogOpen} onClose={() => setAdminNotesDialogOpen(false)}>
|
||||||
|
<View style={tw`p-6`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800 mb-4`}>
|
||||||
|
Admin Notes
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`bg-blue-50 p-4 rounded-lg border border-blue-200`}>
|
||||||
|
<MyText style={tw`text-sm text-blue-900 leading-5`}>
|
||||||
|
{order.adminNotes}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Orders() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { filter } = useLocalSearchParams<{ filter?: string }>();
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<number | null>(null);
|
||||||
|
const [selectedSlotType, setSelectedSlotType] = useState<'slot' | 'flash' | null>(null);
|
||||||
|
const [packagedFilter, setPackagedFilter] = useState<'all' | 'packaged' | 'not_packaged'>('all');
|
||||||
|
const [packagedChecked, setPackagedChecked] = useState(false);
|
||||||
|
const [notPackagedChecked, setNotPackagedChecked] = useState(false);
|
||||||
|
const [deliveredFilter, setDeliveredFilter] = useState<'all' | 'delivered' | 'not_delivered'>('all');
|
||||||
|
const [deliveredChecked, setDeliveredChecked] = useState(false);
|
||||||
|
const [notDeliveredChecked, setNotDeliveredChecked] = useState(false);
|
||||||
|
const [cancellationFilter, setCancellationFilter] = useState<'all' | 'cancelled' | 'not_cancelled'>('all');
|
||||||
|
const [cancelledChecked, setCancelledChecked] = useState(false);
|
||||||
|
const [notCancelledChecked, setNotCancelledChecked] = useState(false);
|
||||||
|
const [flashDeliveryFilter, setFlashDeliveryFilter] = useState<'all' | 'flash' | 'regular'>('all');
|
||||||
|
const [flashChecked, setFlashChecked] = useState(false);
|
||||||
|
const [regularChecked, setRegularChecked] = useState(false);
|
||||||
|
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Handle initial filter from URL params
|
||||||
|
useEffect(() => {
|
||||||
|
if (filter === 'flash') {
|
||||||
|
setSelectedSlotType('flash');
|
||||||
|
setFlashDeliveryFilter('flash');
|
||||||
|
setFlashChecked(true);
|
||||||
|
setRegularChecked(false);
|
||||||
|
}
|
||||||
|
}, [filter]);
|
||||||
|
const { data: slotsData } = trpc.admin.slots.getAll.useQuery();
|
||||||
|
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, refetch } = trpc.admin.order.getAll.useInfiniteQuery(
|
||||||
|
{
|
||||||
|
limit: 20,
|
||||||
|
slotId: selectedSlotType === 'slot' ? selectedSlot : null,
|
||||||
|
packagedFilter,
|
||||||
|
deliveredFilter,
|
||||||
|
cancellationFilter,
|
||||||
|
flashDeliveryFilter: selectedSlotType === 'flash' ? 'flash' : flashDeliveryFilter
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => lastPage?.nextCursor,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const orders = data?.pages.flatMap(page => page?.orders) || [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
<MyText style={tw`text-gray-600 mt-4`}>Loading orders...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotOptions = [
|
||||||
|
{ label: '⚡ Flash Deliveries', value: 'flash' },
|
||||||
|
...(slotsData?.slots?.map(slot => ({
|
||||||
|
label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'),
|
||||||
|
value: slot.id.toString(),
|
||||||
|
})) || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MyFlatList
|
||||||
|
data={orders}
|
||||||
|
keyExtractor={(item) => item!.orderId}
|
||||||
|
renderItem={({ item }) => item ? <OrderItem order={item} refetch={refetch} /> : null}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
onRefresh={() => refetch()}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
<View style={tw`flex-row justify-between items-center p-4 bg-white`}>
|
||||||
|
<View style={tw`flex-1 mr-4`}>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Select Slot"
|
||||||
|
options={slotOptions}
|
||||||
|
value={selectedSlotType === 'flash' ? 'flash' : (selectedSlot?.toString() || '')}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val === 'flash') {
|
||||||
|
setSelectedSlotType('flash');
|
||||||
|
setSelectedSlot(null);
|
||||||
|
setFlashDeliveryFilter('flash');
|
||||||
|
// Reset other filters when switching to flash
|
||||||
|
setPackagedFilter('all');
|
||||||
|
setPackagedChecked(false);
|
||||||
|
setNotPackagedChecked(false);
|
||||||
|
setDeliveredFilter('all');
|
||||||
|
setDeliveredChecked(false);
|
||||||
|
setNotDeliveredChecked(false);
|
||||||
|
setCancellationFilter('all');
|
||||||
|
setCancelledChecked(false);
|
||||||
|
setNotCancelledChecked(false);
|
||||||
|
} else {
|
||||||
|
setSelectedSlotType('slot');
|
||||||
|
setSelectedSlot(val ? Number(val) : null);
|
||||||
|
setFlashDeliveryFilter('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="All slots"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setFilterDialogOpen(true)}
|
||||||
|
style={tw`p-2`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="filter-list" size={24} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{!isLoading && selectedSlotType && (
|
||||||
|
<View style={tw`bg-gray-50 p-3 border-b border-gray-200`}>
|
||||||
|
<MyText style={tw`text-center text-gray-600`}>
|
||||||
|
{selectedSlotType === 'flash'
|
||||||
|
? `${orders.length} Flash delivery orders`
|
||||||
|
: `${orders.length} Orders in slot`
|
||||||
|
}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View style={tw`py-4 items-center flex-row justify-center`}>
|
||||||
|
<ActivityIndicator size="small" color="#3B82F6" />
|
||||||
|
<MyText style={tw`text-gray-600 ml-2`}>Loading more...</MyText>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BottomDialog open={filterDialogOpen} onClose={() => setFilterDialogOpen(false)}>
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`mt-4`}>
|
||||||
|
<MyText style={tw`text-lg font-semibold mb-2`}>Packaged Status</MyText>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={packagedChecked}
|
||||||
|
onPress={() => {
|
||||||
|
const newValue = !packagedChecked;
|
||||||
|
setPackagedChecked(newValue);
|
||||||
|
if (newValue && notPackagedChecked) {
|
||||||
|
setPackagedFilter('all');
|
||||||
|
} else if (newValue) {
|
||||||
|
setPackagedFilter('packaged');
|
||||||
|
} else if (notPackagedChecked) {
|
||||||
|
setPackagedFilter('not_packaged');
|
||||||
|
} else {
|
||||||
|
setPackagedFilter('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`ml-2`}>Packaged</MyText>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={notPackagedChecked}
|
||||||
|
onPress={() => {
|
||||||
|
const newValue = !notPackagedChecked;
|
||||||
|
setNotPackagedChecked(newValue);
|
||||||
|
if (packagedChecked && newValue) {
|
||||||
|
setPackagedFilter('all');
|
||||||
|
} else if (newValue) {
|
||||||
|
setPackagedFilter('not_packaged');
|
||||||
|
} else if (packagedChecked) {
|
||||||
|
setPackagedFilter('packaged');
|
||||||
|
} else {
|
||||||
|
setPackagedFilter('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`ml-2`}>Not Packaged</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={tw`mt-6`}>
|
||||||
|
<MyText style={tw`text-lg font-semibold mb-2`}>Delivered Status</MyText>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={deliveredChecked}
|
||||||
|
onPress={() => {
|
||||||
|
const newValue = !deliveredChecked;
|
||||||
|
setDeliveredChecked(newValue);
|
||||||
|
if (newValue && notDeliveredChecked) {
|
||||||
|
setDeliveredFilter('all');
|
||||||
|
} else if (newValue) {
|
||||||
|
setDeliveredFilter('delivered');
|
||||||
|
} else if (notDeliveredChecked) {
|
||||||
|
setDeliveredFilter('not_delivered');
|
||||||
|
} else {
|
||||||
|
setDeliveredFilter('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`ml-2`}>Delivered</MyText>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={notDeliveredChecked}
|
||||||
|
onPress={() => {
|
||||||
|
const newValue = !notDeliveredChecked;
|
||||||
|
setNotDeliveredChecked(newValue);
|
||||||
|
if (deliveredChecked && newValue) {
|
||||||
|
setDeliveredFilter('all');
|
||||||
|
} else if (newValue) {
|
||||||
|
setDeliveredFilter('not_delivered');
|
||||||
|
} else if (deliveredChecked) {
|
||||||
|
setDeliveredFilter('delivered');
|
||||||
|
} else {
|
||||||
|
setDeliveredFilter('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`ml-2`}>Not Delivered</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={tw`mt-6`}>
|
||||||
|
<MyText style={tw`text-lg font-semibold mb-2`}>Cancellation Status</MyText>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={cancelledChecked}
|
||||||
|
onPress={() => {
|
||||||
|
const newValue = !cancelledChecked;
|
||||||
|
setCancelledChecked(newValue);
|
||||||
|
if (newValue && notCancelledChecked) {
|
||||||
|
setCancellationFilter('all');
|
||||||
|
} else if (newValue) {
|
||||||
|
setCancellationFilter('cancelled');
|
||||||
|
} else if (notCancelledChecked) {
|
||||||
|
setCancellationFilter('not_cancelled');
|
||||||
|
} else {
|
||||||
|
setCancellationFilter('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`ml-2`}>Cancelled</MyText>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={notCancelledChecked}
|
||||||
|
onPress={() => {
|
||||||
|
const newValue = !notCancelledChecked;
|
||||||
|
setNotCancelledChecked(newValue);
|
||||||
|
if (cancelledChecked && newValue) {
|
||||||
|
setCancellationFilter('all');
|
||||||
|
} else if (newValue) {
|
||||||
|
setCancellationFilter('not_cancelled');
|
||||||
|
} else if (cancelledChecked) {
|
||||||
|
setCancellationFilter('cancelled');
|
||||||
|
} else {
|
||||||
|
setCancellationFilter('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`ml-2`}>Not Cancelled</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={tw`mt-6`}>
|
||||||
|
<MyText style={tw`text-lg font-semibold mb-2`}>Delivery Type</MyText>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={flashChecked}
|
||||||
|
onPress={() => {
|
||||||
|
const newValue = !flashChecked;
|
||||||
|
setFlashChecked(newValue);
|
||||||
|
if (newValue && regularChecked) {
|
||||||
|
setFlashDeliveryFilter('all');
|
||||||
|
} else if (newValue) {
|
||||||
|
setFlashDeliveryFilter('flash');
|
||||||
|
} else if (regularChecked) {
|
||||||
|
setFlashDeliveryFilter('regular');
|
||||||
|
} else {
|
||||||
|
setFlashDeliveryFilter('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`ml-2`}>⚡ Flash Delivery</MyText>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={regularChecked}
|
||||||
|
onPress={() => {
|
||||||
|
const newValue = !regularChecked;
|
||||||
|
setRegularChecked(newValue);
|
||||||
|
if (flashChecked && newValue) {
|
||||||
|
setFlashDeliveryFilter('all');
|
||||||
|
} else if (newValue) {
|
||||||
|
setFlashDeliveryFilter('regular');
|
||||||
|
} else if (flashChecked) {
|
||||||
|
setFlashDeliveryFilter('flash');
|
||||||
|
} else {
|
||||||
|
setFlashDeliveryFilter('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`ml-2`}>Regular Delivery</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
</BottomDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-ui/app/(drawer)/prices-overview/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function PricesOverviewLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Prices Overview",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
424
apps/admin-ui/app/(drawer)/prices-overview/index.tsx
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
TouchableOpacity,
|
||||||
|
FlatList,
|
||||||
|
Image,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
TextInput,
|
||||||
|
} from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import {
|
||||||
|
AppContainer,
|
||||||
|
MyText,
|
||||||
|
tw,
|
||||||
|
BottomDialog,
|
||||||
|
BottomDropdown,
|
||||||
|
Checkbox,
|
||||||
|
} from "common-ui";
|
||||||
|
import { trpc } from "@/src/trpc-client";
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { Entypo } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
interface ProductItemProps {
|
||||||
|
item: any;
|
||||||
|
hasChanges: (productId: number) => boolean;
|
||||||
|
pendingChanges: Record<string, any>;
|
||||||
|
setPendingChanges: React.Dispatch<React.SetStateAction<Record<string, any>>>;
|
||||||
|
openEditDialog: (product: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductItemComponent: React.FC<ProductItemProps> = ({
|
||||||
|
item: product,
|
||||||
|
hasChanges,
|
||||||
|
pendingChanges,
|
||||||
|
setPendingChanges,
|
||||||
|
openEditDialog,
|
||||||
|
}) => {
|
||||||
|
const changed = hasChanges(product.id);
|
||||||
|
const change = pendingChanges[product.id] || {};
|
||||||
|
const displayPrice = change.price !== undefined ? change.price : product.price;
|
||||||
|
const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : product.marketPrice;
|
||||||
|
const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : product.flashPrice;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`bg-white p-4 mb-3 rounded-xl border border-gray-200 shadow-sm`}>
|
||||||
|
{/* Change indicator */}
|
||||||
|
<View style={tw`absolute top-2 right-2`}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
tw`w-4 h-4 rounded-full items-center justify-center`,
|
||||||
|
changed ? tw`bg-green-500` : tw`bg-gray-300`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{changed && <MaterialIcons name="check" size={10} color="white" />}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* First row: Image and Name */}
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
{/* Product image */}
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: product.images?.[0] || "https://via.placeholder.com/32x32?text=No+Image"
|
||||||
|
}}
|
||||||
|
style={tw`w-10 h-10 rounded-lg mr-3`}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Product name and Flash Checkbox */}
|
||||||
|
<View style={tw`flex-1 flex-row items-center`}>
|
||||||
|
<MyText style={tw`text-base font-medium text-gray-800`} numberOfLines={1}>
|
||||||
|
{product.name.length > 25 ? product.name.substring(0, 25) + '...' : product.name}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row items-center ml-2`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={change.isFlashAvailable ?? product.isFlashAvailable ?? false}
|
||||||
|
onPress={() => {
|
||||||
|
const currentValue = change.isFlashAvailable ?? product.isFlashAvailable ?? false;
|
||||||
|
setPendingChanges(prev => ({
|
||||||
|
...prev,
|
||||||
|
[product.id]: {
|
||||||
|
...change,
|
||||||
|
isFlashAvailable: !currentValue,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={tw`mr-1`}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>Flash</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Second row: Prices */}
|
||||||
|
<View style={tw`flex-row items-center justify-between`}>
|
||||||
|
{/* Our Price */}
|
||||||
|
<View style={tw`items-center flex-1`}>
|
||||||
|
<MyText style={tw`text-xs text-gray-500 mb-1`}>Our Price</MyText>
|
||||||
|
<View style={tw`flex-row items-center justify-center`}>
|
||||||
|
<MyText style={tw`text-sm font-bold text-green-600`}>₹{displayPrice}</MyText>
|
||||||
|
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
||||||
|
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Market Price */}
|
||||||
|
<View style={tw`items-center flex-1`}>
|
||||||
|
<MyText style={tw`text-xs text-gray-500 mb-1`}>Market Price</MyText>
|
||||||
|
<View style={tw`flex-row items-center justify-center`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-600`}>{displayMarketPrice ? `₹${displayMarketPrice}` : "N/A"}</MyText>
|
||||||
|
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
||||||
|
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Flash Price */}
|
||||||
|
<View style={tw`items-center flex-1`}>
|
||||||
|
<MyText style={tw`text-xs text-gray-500 mb-1`}>Flash Price</MyText>
|
||||||
|
<View style={tw`flex-row items-center justify-center`}>
|
||||||
|
<MyText style={tw`text-sm text-orange-600`}>{displayFlashPrice ? `₹${displayFlashPrice}` : "N/A"}</MyText>
|
||||||
|
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
||||||
|
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PendingChange {
|
||||||
|
price?: number;
|
||||||
|
marketPrice?: number | null;
|
||||||
|
flashPrice?: number | null;
|
||||||
|
isFlashAvailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditDialogState {
|
||||||
|
open: boolean;
|
||||||
|
product: any;
|
||||||
|
tempPrice: string;
|
||||||
|
tempMarketPrice: string;
|
||||||
|
tempFlashPrice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PricesOverview() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedStores, setSelectedStores] = useState<string[]>([]);
|
||||||
|
const [pendingChanges, setPendingChanges] = useState<Record<number, PendingChange>>({});
|
||||||
|
const [editDialog, setEditDialog] = useState<EditDialogState>({
|
||||||
|
open: false,
|
||||||
|
product: null,
|
||||||
|
tempPrice: "",
|
||||||
|
tempMarketPrice: "",
|
||||||
|
tempFlashPrice: "",
|
||||||
|
});
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
|
const { data: productsData, isLoading: productsLoading, refetch: refetchProducts } =
|
||||||
|
trpc.admin.product.getProducts.useQuery();
|
||||||
|
const { data: storesData, isLoading: storesLoading } =
|
||||||
|
trpc.admin.store.getStores.useQuery();
|
||||||
|
|
||||||
|
const updatePricesMutation = trpc.admin.product.updateProductPrices.useMutation();
|
||||||
|
|
||||||
|
const stores = storesData?.stores || [];
|
||||||
|
const allProducts = productsData?.products || [];
|
||||||
|
|
||||||
|
// Sort stores alphabetically
|
||||||
|
const sortedStores = useMemo(() =>
|
||||||
|
[...stores].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[stores]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store options for dropdown
|
||||||
|
const storeOptions = useMemo(() =>
|
||||||
|
sortedStores.map(store => ({
|
||||||
|
label: store.name,
|
||||||
|
value: store.id.toString(),
|
||||||
|
})),
|
||||||
|
[sortedStores]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize selectedStores to all if not set
|
||||||
|
useEffect(() => {
|
||||||
|
if (stores.length > 0 && selectedStores.length === 0) {
|
||||||
|
setSelectedStores(stores.map(s => s.id.toString()));
|
||||||
|
}
|
||||||
|
}, [stores, selectedStores]);
|
||||||
|
|
||||||
|
// Filter products by selected stores
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
if (selectedStores.length === 0) return allProducts;
|
||||||
|
return allProducts.filter(product =>
|
||||||
|
product.storeId && selectedStores.includes(product.storeId.toString())
|
||||||
|
);
|
||||||
|
}, [allProducts, selectedStores]);
|
||||||
|
|
||||||
|
// Check if a product has changes
|
||||||
|
const hasChanges = (productId: number) => !!pendingChanges[productId];
|
||||||
|
|
||||||
|
// Open edit dialog
|
||||||
|
const openEditDialog = (product: any) => {
|
||||||
|
const change = pendingChanges[product.id] || {};
|
||||||
|
setEditDialog({
|
||||||
|
open: true,
|
||||||
|
product,
|
||||||
|
tempPrice: (change.price ?? product.price)?.toString() || "",
|
||||||
|
tempMarketPrice: (change.marketPrice ?? product.marketPrice)?.toString() || "",
|
||||||
|
tempFlashPrice: (change.flashPrice ?? product.flashPrice)?.toString() || "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save edit dialog
|
||||||
|
const saveEditDialog = () => {
|
||||||
|
const price = parseFloat(editDialog.tempPrice);
|
||||||
|
const marketPrice = editDialog.tempMarketPrice ? parseFloat(editDialog.tempMarketPrice) : null;
|
||||||
|
const flashPrice = editDialog.tempFlashPrice ? parseFloat(editDialog.tempFlashPrice) : null;
|
||||||
|
|
||||||
|
if (isNaN(price) || price <= 0) {
|
||||||
|
Alert.alert("Error", "Please enter a valid price");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editDialog.tempMarketPrice && (isNaN(marketPrice!) || marketPrice! <= 0)) {
|
||||||
|
Alert.alert("Error", "Please enter a valid market price");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editDialog.tempFlashPrice && (isNaN(flashPrice!) || flashPrice! <= 0)) {
|
||||||
|
Alert.alert("Error", "Please enter a valid flash price");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingChanges(prev => ({
|
||||||
|
...prev,
|
||||||
|
[editDialog.product.id]: {
|
||||||
|
price: price !== editDialog.product.price ? price : undefined,
|
||||||
|
marketPrice: marketPrice !== editDialog.product.marketPrice ? marketPrice : undefined,
|
||||||
|
flashPrice: flashPrice !== editDialog.product.flashPrice ? flashPrice : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setEditDialog({ open: false, product: null, tempPrice: "", tempMarketPrice: "", tempFlashPrice: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle save all changes
|
||||||
|
const handleSave = () => {
|
||||||
|
const updates = Object.entries(pendingChanges).map(([productId, change]) => {
|
||||||
|
const update: any = { productId: parseInt(productId) };
|
||||||
|
if (change.price !== undefined) update.price = change.price;
|
||||||
|
if (change.marketPrice !== undefined) update.marketPrice = change.marketPrice;
|
||||||
|
if (change.flashPrice !== undefined) update.flashPrice = change.flashPrice;
|
||||||
|
if (change.isFlashAvailable !== undefined) update.isFlashAvailable = change.isFlashAvailable;
|
||||||
|
return update;
|
||||||
|
});
|
||||||
|
|
||||||
|
updatePricesMutation.mutate(
|
||||||
|
{ updates },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setPendingChanges({});
|
||||||
|
refetchProducts();
|
||||||
|
Alert.alert("Success", "Prices updated successfully");
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert("Error", `Failed to update prices: ${error.message || "Unknown error"}`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const changeCount = Object.keys(pendingChanges).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
{/* Stores filter, save button, and menu */}
|
||||||
|
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center`}>
|
||||||
|
<View style={tw`flex-1 mr-4`}>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Filter by Stores"
|
||||||
|
options={storeOptions}
|
||||||
|
value={selectedStores}
|
||||||
|
onValueChange={(value) => setSelectedStores(value as string[])}
|
||||||
|
multiple={true}
|
||||||
|
placeholder="Select stores"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
tw`px-4 py-2 rounded-lg flex-row items-center mr-3`,
|
||||||
|
changeCount > 0 && !updatePricesMutation.isPending ? tw`bg-blue-600` : tw`bg-gray-300`,
|
||||||
|
]}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={changeCount === 0 || updatePricesMutation.isPending}
|
||||||
|
>
|
||||||
|
{updatePricesMutation.isPending ? (
|
||||||
|
<ActivityIndicator size="small" color={changeCount > 0 ? "white" : "#6b7280"} style={tw`mr-2`} />
|
||||||
|
) : (
|
||||||
|
<MaterialIcons
|
||||||
|
name="save"
|
||||||
|
size={20}
|
||||||
|
color={changeCount > 0 ? "white" : "#6b7280"}
|
||||||
|
style={tw`mr-2`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MyText
|
||||||
|
style={[
|
||||||
|
tw`font-medium`,
|
||||||
|
changeCount > 0 ? tw`text-white` : tw`text-gray-500`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Save ({changeCount})
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowMenu(true)}
|
||||||
|
style={tw`p-2 -mr-2`}
|
||||||
|
>
|
||||||
|
<Entypo name="dots-three-vertical" size={16} color="#9CA3AF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{productsLoading || storesLoading ? (
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<ActivityIndicator size="large" color="#3b82f6" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4`}>Loading...</MyText>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={filteredProducts}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ProductItemComponent
|
||||||
|
item={item}
|
||||||
|
hasChanges={hasChanges}
|
||||||
|
pendingChanges={pendingChanges}
|
||||||
|
setPendingChanges={setPendingChanges}
|
||||||
|
openEditDialog={openEditDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
contentContainerStyle={tw`p-4`}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<BottomDialog open={editDialog.open} onClose={() => setEditDialog({ ...editDialog, open: false, tempFlashPrice: "" })}>
|
||||||
|
<View style={tw`p-4`}>
|
||||||
|
<MyText style={tw`text-lg font-bold mb-4`}>{editDialog.product?.name}</MyText>
|
||||||
|
|
||||||
|
<View style={tw`mb-4`}>
|
||||||
|
<MyText style={tw`text-sm font-medium mb-1`}>Our Price</MyText>
|
||||||
|
<TextInput
|
||||||
|
style={tw`border border-gray-300 rounded-md px-3 py-2`}
|
||||||
|
value={editDialog.tempPrice}
|
||||||
|
onChangeText={(text) => setEditDialog({ ...editDialog, tempPrice: text })}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="Enter price"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`mb-4`}>
|
||||||
|
<MyText style={tw`text-sm font-medium mb-1`}>Market Price (Optional)</MyText>
|
||||||
|
<TextInput
|
||||||
|
style={tw`border border-gray-300 rounded-md px-3 py-2`}
|
||||||
|
value={editDialog.tempMarketPrice}
|
||||||
|
onChangeText={(text) => setEditDialog({ ...editDialog, tempMarketPrice: text })}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="Enter market price"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`mb-4`}>
|
||||||
|
<MyText style={tw`text-sm font-medium mb-1`}>Flash Price (Optional)</MyText>
|
||||||
|
<TextInput
|
||||||
|
style={tw`border border-gray-300 rounded-md px-3 py-2`}
|
||||||
|
value={editDialog.tempFlashPrice}
|
||||||
|
onChangeText={(text) => setEditDialog({ ...editDialog, tempFlashPrice: text })}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="Enter flash price"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`bg-blue-600 py-3 rounded-md items-center`}
|
||||||
|
onPress={saveEditDialog}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-medium`}>Update Price</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
|
||||||
|
{/* Menu Dialog */}
|
||||||
|
<BottomDialog open={showMenu} onClose={() => setShowMenu(false)}>
|
||||||
|
<View style={tw`p-6`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800 mb-6`}>
|
||||||
|
Options
|
||||||
|
</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg`}
|
||||||
|
onPress={() => {
|
||||||
|
router.push('/rebalance-orders' as any);
|
||||||
|
setShowMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Entypo name="shuffle" size={20} color="#6B7280" />
|
||||||
|
<MyText style={tw`text-gray-800 font-medium ml-3`}>
|
||||||
|
Re-Balance Orders
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
|
||||||
|
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
547
apps/admin-ui/app/(drawer)/product-detail/[id].tsx
Normal file
|
|
@ -0,0 +1,547 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, ScrollView, TouchableOpacity, Alert, TextInput, Dimensions, ActivityIndicator, Platform } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useRouter, useLocalSearchParams, Stack } from 'expo-router';
|
||||||
|
import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploader, ImageCarousel } from 'common-ui';
|
||||||
|
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
|
import { Formik } from 'formik';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import Animated, { FadeInDown, FadeInUp, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
const { width: screenWidth } = Dimensions.get("window");
|
||||||
|
const carouselHeight = screenWidth * 0.85;
|
||||||
|
|
||||||
|
interface ReviewResponseFormProps {
|
||||||
|
reviewId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClose }) => {
|
||||||
|
const [adminResponse, setAdminResponse] = useState('');
|
||||||
|
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||||
|
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||||
|
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
||||||
|
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
|
const handleImagePick = usePickImage({
|
||||||
|
setFile: async (assets: any) => {
|
||||||
|
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||||
|
setSelectedImages([]);
|
||||||
|
setDisplayImages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.isArray(assets) ? assets : [assets];
|
||||||
|
const blobPromises = files.map(async (asset) => {
|
||||||
|
const response = await fetch(asset.uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return { blob, mimeType: asset.mimeType || 'image/jpeg' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const blobArray = await Promise.all(blobPromises);
|
||||||
|
setSelectedImages(blobArray);
|
||||||
|
setDisplayImages(files.map(asset => ({ uri: asset.uri })));
|
||||||
|
},
|
||||||
|
multiple: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemoveImage = (uri: string) => {
|
||||||
|
const index = displayImages.findIndex(img => img.uri === uri);
|
||||||
|
if (index !== -1) {
|
||||||
|
const newDisplay = displayImages.filter((_, i) => i !== index);
|
||||||
|
const newFiles = selectedImages.filter((_, i) => i !== index);
|
||||||
|
setDisplayImages(newDisplay);
|
||||||
|
setSelectedImages(newFiles);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (adminResponse: string) => {
|
||||||
|
try {
|
||||||
|
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||||
|
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
|
||||||
|
contextString: 'review',
|
||||||
|
mimeTypes,
|
||||||
|
});
|
||||||
|
const keys = generatedUrls.map(url => {
|
||||||
|
const u = new URL(url);
|
||||||
|
const rawKey = u.pathname.replace(/^\/+/, "");
|
||||||
|
const decodedKey = decodeURIComponent(rawKey);
|
||||||
|
const parts = decodedKey.split('/');
|
||||||
|
parts.shift();
|
||||||
|
return parts.join('/');
|
||||||
|
});
|
||||||
|
setUploadUrls(generatedUrls);
|
||||||
|
|
||||||
|
for (let i = 0; i < generatedUrls.length; i++) {
|
||||||
|
const uploadUrl = generatedUrls[i];
|
||||||
|
const { blob, mimeType } = selectedImages[i];
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: blob,
|
||||||
|
headers: { 'Content-Type': mimeType },
|
||||||
|
});
|
||||||
|
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await respondToReview.mutateAsync({
|
||||||
|
reviewId,
|
||||||
|
adminResponse,
|
||||||
|
adminResponseImages: keys,
|
||||||
|
uploadUrls: generatedUrls,
|
||||||
|
});
|
||||||
|
|
||||||
|
Alert.alert('Success', 'Response submitted');
|
||||||
|
onClose();
|
||||||
|
setAdminResponse('');
|
||||||
|
setSelectedImages([]);
|
||||||
|
setDisplayImages([]);
|
||||||
|
setUploadUrls([]);
|
||||||
|
} catch (error:any) {
|
||||||
|
|
||||||
|
Alert.alert('Error', error.message || 'Failed to submit response.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{ adminResponse: '', images: [] }}
|
||||||
|
onSubmit={(values) => handleSubmit(values.adminResponse)}
|
||||||
|
>
|
||||||
|
{({ handleChange, handleSubmit: formikSubmit, values }) => (
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
style={tw`border border-gray-200 bg-gray-50 rounded-2xl p-4 mb-4 h-32 text-gray-900 text-base shadow-sm`}
|
||||||
|
placeholder="Write your response here..."
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={values.adminResponse}
|
||||||
|
onChangeText={handleChange('adminResponse')}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={tw`mb-6`}>
|
||||||
|
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Attach Images</MyText>
|
||||||
|
<ImageUploader
|
||||||
|
images={displayImages}
|
||||||
|
existingImageUrls={[]}
|
||||||
|
onAddImage={handleImagePick}
|
||||||
|
onRemoveImage={handleRemoveImage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => formikSubmit()}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={respondToReview.isPending}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#2563EB', '#1D4ED8']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={tw`py-4 rounded-2xl items-center shadow-lg`}
|
||||||
|
>
|
||||||
|
{respondToReview.isPending ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductDetail() {
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const productId = parseInt(id as string);
|
||||||
|
|
||||||
|
const { data: productData, isLoading, error, refetch } = trpc.admin.product.getProductById.useQuery({ id: productId });
|
||||||
|
const { data: reviewsData } = trpc.admin.product.getProductReviews.useQuery({ productId });
|
||||||
|
const [responseDialogOpen, setResponseDialogOpen] = useState(false);
|
||||||
|
const [selectedReview, setSelectedReview] = useState<any>(null);
|
||||||
|
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation();
|
||||||
|
|
||||||
|
const product = productData?.product;
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
router.push(`/edit-product?id=${productId}` as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
<MyText style={tw`text-gray-500 mt-4 font-medium`}>Loading...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !product) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<MaterialIcons name="error-outline" size={64} color="#EF4444" />
|
||||||
|
<MyText style={tw`text-gray-900 text-xl font-bold mt-4`}>Product Not Found</MyText>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={tw`mt-6 px-8 py-3 bg-gray-100 rounded-full`}>
|
||||||
|
<Text style={tw`text-gray-800 font-semibold`}>Go Back</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
|
||||||
|
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-32`} showsVerticalScrollIndicator={false}>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<View style={tw`relative`}>
|
||||||
|
{product.images && product.images.length > 0 ? (
|
||||||
|
<ImageCarousel
|
||||||
|
urls={product.images}
|
||||||
|
imageWidth={screenWidth}
|
||||||
|
imageHeight={carouselHeight}
|
||||||
|
showPaginationDots={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ width: screenWidth, height: carouselHeight, backgroundColor: '#E5E7EB', alignItems: 'center', justifyContent: 'center' }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gradient Overlay */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', 'rgba(0,0,0,0.05)', 'rgba(0,0,0,0.3)']}
|
||||||
|
style={tw`absolute bottom-0 left-0 right-0 h-24`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating Header Buttons */}
|
||||||
|
<View style={tw`absolute top-${Platform.OS === 'ios' ? '12' : '8'} left-4 right-4 flex-row justify-between items-center z-10`}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} activeOpacity={0.8}>
|
||||||
|
<BlurView intensity={80} tint="dark" style={tw`w-10 h-10 rounded-full items-center justify-center overflow-hidden`}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color="white" />
|
||||||
|
</BlurView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={handleEdit} activeOpacity={0.8}>
|
||||||
|
<BlurView intensity={80} tint="dark" style={tw`px-4 py-2 rounded-full flex-row items-center overflow-hidden`}>
|
||||||
|
<Feather name="edit-2" size={16} color="white" />
|
||||||
|
<Text style={tw`text-white font-bold ml-2`}>Edit</Text>
|
||||||
|
</BlurView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Container - Overlapping the image slightly */}
|
||||||
|
<View style={tw`-mt-8 rounded-t-[32px] bg-gray-50 overflow-hidden`}>
|
||||||
|
|
||||||
|
{/* Main Info Card */}
|
||||||
|
<Animated.View entering={FadeInUp.delay(100).duration(500)} style={tw`bg-white px-6 pt-8 pb-6 rounded-b-[32px] shadow-sm mb-4`}>
|
||||||
|
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||||
|
<MyText style={tw`text-3xl font-extrabold text-gray-900 flex-1 mr-4 leading-tight`}>{product.name}</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
toggleOutOfStock.mutate({ id: productId }, {
|
||||||
|
onSuccess: () => Alert.alert('Success', 'Stock status updated'),
|
||||||
|
onError: (err) => Alert.alert('Error', err.message)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={product.isOutOfStock ? ['#EF4444', '#DC2626'] : ['#10B981', '#059669']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={tw`px-4 py-1.5 rounded-full shadow-sm`}
|
||||||
|
>
|
||||||
|
<Text style={tw`text-white text-xs font-bold uppercase tracking-wide`}>
|
||||||
|
{product.isOutOfStock ? 'Out of Stock' : 'In Stock'}
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-end mt-2`}>
|
||||||
|
<Text style={tw`text-4xl font-black text-gray-900`}>₹{product.price}</Text>
|
||||||
|
<Text style={tw`text-gray-500 text-xl font-medium mb-1.5 ml-2`}>/ {product.unit?.shortNotation}</Text>
|
||||||
|
{product.marketPrice && (
|
||||||
|
<View style={tw`ml-4 mb-2 px-2 py-0.5 bg-red-50 rounded`}>
|
||||||
|
<Text style={tw`text-red-400 text-base line-through font-medium`}>₹{product.marketPrice}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Increment Step Info */}
|
||||||
|
<View style={tw`mt-3 flex-row items-center`}>
|
||||||
|
<View style={tw`bg-blue-50 px-3 py-1.5 rounded-full border border-blue-100`}>
|
||||||
|
<Text style={tw`text-blue-700 text-sm font-bold`}>
|
||||||
|
Increment: {product.incrementStep || 1}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quick Stats Row */}
|
||||||
|
<View style={tw`flex-row mt-6 pt-6 border-t border-gray-100`}>
|
||||||
|
<View style={tw`flex-1 items-center border-r border-gray-100`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MaterialIcons name="star" size={20} color="#F59E0B" />
|
||||||
|
<Text style={tw`text-lg font-bold text-gray-900 ml-1`}>
|
||||||
|
{reviewsData?.reviews.reduce((acc, r) => acc + r.ratings, 0)
|
||||||
|
? (reviewsData.reviews.reduce((acc, r) => acc + r.ratings, 0) / reviewsData.reviews.length).toFixed(1)
|
||||||
|
: '-'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Rating</Text>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1 items-center border-r border-gray-100`}>
|
||||||
|
<Text style={tw`text-lg font-bold text-gray-900`}>{reviewsData?.reviews.length || 0}</Text>
|
||||||
|
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Reviews</Text>
|
||||||
|
</View>
|
||||||
|
{/* <View style={tw`flex-1 items-center`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
toggleOutOfStock.mutate({ id: productId }, {
|
||||||
|
onSuccess: () => Alert.alert('Success', 'Stock status updated'),
|
||||||
|
onError: (err) => Alert.alert('Error', err.message)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={product.isOutOfStock ? ['#EF4444', '#DC2626'] : ['#10B981', '#059669']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={tw`px-3 py-1 rounded-full shadow-sm`}
|
||||||
|
>
|
||||||
|
<Text style={tw`text-white text-xs font-bold uppercase tracking-wide`}>
|
||||||
|
{product.isOutOfStock ? 'Out of Stock' : 'In Stock'}
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Stock</Text>
|
||||||
|
</View> */}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Animated.View entering={FadeInDown.delay(200).duration(500)} style={tw`px-4 mb-4`}>
|
||||||
|
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
|
||||||
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
|
<View style={tw`w-10 h-10 bg-blue-50 rounded-full items-center justify-center mr-3`}>
|
||||||
|
<MaterialCommunityIcons name="text-box-outline" size={22} color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
<Text style={tw`text-lg font-bold text-gray-900`}>Description</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{product.shortDescription && (
|
||||||
|
<Text style={tw`text-gray-900 font-medium text-base mb-3 leading-relaxed`}>{product.shortDescription}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={tw`text-gray-600 leading-7 text-base`}>
|
||||||
|
{product.longDescription || "No detailed description available for this product."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Availability */}
|
||||||
|
<Animated.View entering={FadeInDown.delay(250).duration(500)} style={tw`px-4 mb-4`}>
|
||||||
|
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
|
||||||
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
|
<View style={tw`w-10 h-10 bg-blue-50 rounded-full items-center justify-center mr-3`}>
|
||||||
|
<MaterialIcons name="inventory" size={22} color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
<Text style={tw`text-lg font-bold text-gray-900`}>Availability</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={tw`text-gray-600 mb-4`}>
|
||||||
|
This product is currently {product.isOutOfStock ? 'out of stock' : 'in stock'}.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
toggleOutOfStock.mutate({ id: productId }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert('Success', 'Stock status updated');
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert('Error', err.message)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={tw`bg-gray-100 px-4 py-2 rounded-full border border-gray-200 self-start`}
|
||||||
|
>
|
||||||
|
<Text style={tw`text-gray-700 font-bold text-sm`}>
|
||||||
|
Mark as {product.isOutOfStock ? 'In Stock' : 'Out of Stock'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Special Deals */}
|
||||||
|
{product.deals && product.deals.length > 0 && (
|
||||||
|
<Animated.View entering={FadeInDown.delay(300).duration(500)} style={tw`px-4 mb-4`}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#FFFBEB', '#FEF3C7']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={tw`p-6 rounded-3xl shadow-sm border border-amber-100`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
|
<View style={tw`w-10 h-10 bg-amber-100 rounded-full items-center justify-center mr-3`}>
|
||||||
|
<MaterialIcons name="local-offer" size={22} color="#D97706" />
|
||||||
|
</View>
|
||||||
|
<Text style={tw`text-lg font-bold text-amber-900`}>Special Deals</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{product.deals.map((deal, index) => (
|
||||||
|
<View key={index} style={tw`flex-row justify-between items-center bg-white/60 p-4 rounded-2xl mb-2 border border-amber-200/50`}>
|
||||||
|
<View>
|
||||||
|
<Text style={tw`text-amber-900 font-bold text-lg`}>Buy {deal.quantity}</Text>
|
||||||
|
<Text style={tw`text-amber-700 text-xs font-medium`}>Valid until {new Date(deal.validTill).toLocaleDateString()}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={tw`items-end`}>
|
||||||
|
<Text style={tw`text-2xl font-black text-amber-600`}>₹{deal.price}</Text>
|
||||||
|
<Text style={tw`text-amber-700 text-xs font-medium`}>Total Price</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</LinearGradient>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reviews Section */}
|
||||||
|
<Animated.View entering={FadeInDown.delay(400).duration(500)} style={tw`px-4 mb-8`}>
|
||||||
|
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-6`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<View style={tw`w-10 h-10 bg-purple-50 rounded-full items-center justify-center mr-3`}>
|
||||||
|
<MaterialIcons name="rate-review" size={22} color="#7C3AED" />
|
||||||
|
</View>
|
||||||
|
<Text style={tw`text-lg font-bold text-gray-900`}>Reviews</Text>
|
||||||
|
</View>
|
||||||
|
<View style={tw`bg-gray-100 px-3 py-1 rounded-full`}>
|
||||||
|
<Text style={tw`text-xs font-bold text-gray-600`}>{reviewsData?.reviews.length || 0} Total</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{reviewsData && reviewsData.reviews.length > 0 ? (
|
||||||
|
reviewsData.reviews.map((review, idx) => (
|
||||||
|
<View key={review.id} style={tw`mb-6 last:mb-0`}>
|
||||||
|
<View style={tw`flex-row items-start`}>
|
||||||
|
<View style={tw`flex-1 bg-gray-50 p-4 rounded-2xl`}>
|
||||||
|
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||||
|
<Text style={tw`font-bold text-gray-900 text-base`}>{review.userName}</Text>
|
||||||
|
<View style={tw`flex-row`}>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<MaterialIcons
|
||||||
|
key={star}
|
||||||
|
name={star <= review.ratings ? 'star' : 'star-border'}
|
||||||
|
size={14}
|
||||||
|
color="#F59E0B"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={tw`text-gray-700 leading-relaxed mb-3`}>{review.reviewBody}</Text>
|
||||||
|
|
||||||
|
{review.signedImageUrls && review.signedImageUrls.length > 0 && (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={tw`mb-3`}>
|
||||||
|
{review.signedImageUrls.map((url, index) => (
|
||||||
|
<Image key={index} source={{ uri: url }} style={tw`w-16 h-16 rounded-lg mr-2 bg-gray-200`} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin Response Section */}
|
||||||
|
{review.adminResponse ? (
|
||||||
|
<View style={tw`mt-3 pt-3 border-t border-gray-200`}>
|
||||||
|
<View style={tw`flex-row items-center mb-1`}>
|
||||||
|
<MaterialIcons name="verified-user" size={14} color="#2563EB" />
|
||||||
|
<Text style={tw`text-blue-700 font-bold text-xs ml-1`}>Admin Response</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={tw`text-gray-600 text-sm leading-relaxed`}>{review.adminResponse}</Text>
|
||||||
|
{review.signedAdminImageUrls && review.signedAdminImageUrls.length > 0 && (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={tw`mt-2`}>
|
||||||
|
{review.signedAdminImageUrls.map((url, index) => (
|
||||||
|
<Image key={index} source={{ uri: url }} style={tw`w-12 h-12 rounded-lg mr-2 bg-white`} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedReview(review);
|
||||||
|
setResponseDialogOpen(true);
|
||||||
|
}}
|
||||||
|
style={tw`mt-2 self-end px-4 py-2 bg-white rounded-full border border-gray-200 shadow-sm flex-row items-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="reply" size={16} color="#059669" />
|
||||||
|
<Text style={tw`text-gray-700 font-bold text-xs ml-1`}>Reply</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View style={tw`py-10 items-center justify-center opacity-50`}>
|
||||||
|
<MaterialIcons name="chat-bubble-outline" size={48} color="#9CA3AF" />
|
||||||
|
<Text style={tw`text-gray-400 mt-2 font-medium`}>No reviews yet</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Response Dialog */}
|
||||||
|
<BottomDialog open={responseDialogOpen} onClose={() => setResponseDialogOpen(false)}>
|
||||||
|
<View style={tw`p-6 max-h-[700px]`}>
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-6`}>
|
||||||
|
<Text style={tw`text-2xl font-bold text-gray-900`}>Reply to Review</Text>
|
||||||
|
<TouchableOpacity onPress={() => setResponseDialogOpen(false)} style={tw`p-2 bg-gray-100 rounded-full`}>
|
||||||
|
<MaterialIcons name="close" size={20} color="#6B7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedReview && (
|
||||||
|
<View>
|
||||||
|
<View style={tw`bg-gray-50 p-4 rounded-2xl mb-6 border border-gray-100`}>
|
||||||
|
<View style={tw`flex-row items-center mb-2`}>
|
||||||
|
<MaterialIcons name="format-quote" size={20} color="#9CA3AF" />
|
||||||
|
<Text style={tw`text-xs font-bold text-gray-500 uppercase ml-1`}>Replying to {selectedReview.userName}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={tw`text-gray-700 italic text-base leading-relaxed pl-2 border-l-2 border-gray-300`} numberOfLines={3}>
|
||||||
|
{selectedReview.reviewBody}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ReviewResponseForm
|
||||||
|
reviewId={selectedReview.id}
|
||||||
|
onClose={() => {
|
||||||
|
setResponseDialogOpen(false);
|
||||||
|
setSelectedReview(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/product-detail/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="[id]" options={{ title: 'Product Detail' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/product-groupings/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Product Groupings' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
apps/admin-ui/app/(drawer)/product-groupings/index.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, ScrollView, TouchableOpacity, Alert } from "react-native";
|
||||||
|
import {
|
||||||
|
theme,
|
||||||
|
AppContainer,
|
||||||
|
MyText,
|
||||||
|
tw,
|
||||||
|
useManualRefresh,
|
||||||
|
useMarkDataFetchers,
|
||||||
|
MyTouchableOpacity,
|
||||||
|
BottomDialog,
|
||||||
|
} from "common-ui";
|
||||||
|
import { trpc } from "../../../src/trpc-client";
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
interface ProductGroup {
|
||||||
|
id: number;
|
||||||
|
groupName: string;
|
||||||
|
description: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
products: any[];
|
||||||
|
productCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupItem = ({
|
||||||
|
group,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onViewProducts,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
group: ProductGroup;
|
||||||
|
onEdit: (group: ProductGroup) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
onViewProducts: (products: any[]) => void;
|
||||||
|
index: number;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View style={tw``}>
|
||||||
|
<View style={tw`px-2 py-6`}>
|
||||||
|
{/* Top Header: Name & Actions */}
|
||||||
|
<View style={tw`flex-row justify-between items-start mb-6`}>
|
||||||
|
<View style={tw`flex-row items-center flex-1`}>
|
||||||
|
<View
|
||||||
|
style={tw`w-12 h-12 rounded-2xl bg-brand50 items-center justify-center mr-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="group"
|
||||||
|
size={24}
|
||||||
|
color={theme.colors.brand600}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-slate-400 text-[10px] font-black uppercase tracking-widest`}
|
||||||
|
>
|
||||||
|
Group Name
|
||||||
|
</MyText>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-xl font-black text-slate-900`}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{group.groupName}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onEdit(group)}
|
||||||
|
style={tw`p-2 mr-2`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="edit" size={20} color="#64748B" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onDelete(group.id)}
|
||||||
|
style={tw`p-2`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="delete" size={20} color="#EF4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{group.description && (
|
||||||
|
<View
|
||||||
|
style={tw`bg-slate-50 rounded-2xl p-4 mb-6 border border-slate-100`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-slate-700 font-medium`}>
|
||||||
|
{group.description}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<View style={tw`flex-row items-center justify-between`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onViewProducts(group.products)}
|
||||||
|
style={tw`flex-row items-center mr-4`}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="inventory"
|
||||||
|
size={14}
|
||||||
|
color={theme.colors.brand500}
|
||||||
|
/>
|
||||||
|
<MyText style={tw`text-xs font-bold text-brand500 ml-1.5`}>
|
||||||
|
{group.productCount} Products
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MaterialIcons name="schedule" size={14} color="#94A3B8" />
|
||||||
|
<MyText style={tw`text-xs font-bold text-slate-500 ml-1.5`}>
|
||||||
|
{dayjs(group.createdAt).format("MMM DD, YYYY")}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductGroupings() {
|
||||||
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
data: groupsData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = trpc.admin.product.getGroups.useQuery();
|
||||||
|
const deleteGroup = trpc.admin.product.deleteGroup.useMutation();
|
||||||
|
|
||||||
|
const groups = groupsData?.groups || [];
|
||||||
|
const [viewProducts, setViewProducts] = useState<any[] | null>(null);
|
||||||
|
|
||||||
|
useManualRefresh(refetch);
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
router.push("/(drawer)/create-product-group");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (group: ProductGroup) => {
|
||||||
|
router.push(`/(drawer)/edit-product-group/${group.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
Alert.alert("Delete Group", "Are you sure you want to delete this group?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
deleteGroup.mutate(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-gray-600`}>Loading product groups...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-red-600`}>Error loading groups</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 relative px-4 bg-white`}>
|
||||||
|
<ScrollView
|
||||||
|
style={[tw`flex-1`]}
|
||||||
|
contentContainerStyle={tw`pb-32`}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
bounces={false}
|
||||||
|
>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-8`}>
|
||||||
|
<View
|
||||||
|
style={tw`w-24 h-24 bg-slate-50 rounded-full items-center justify-center mb-6`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="group-work" size={48} color="#94A3B8" />
|
||||||
|
</View>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-slate-900 text-xl font-black tracking-tight`}
|
||||||
|
>
|
||||||
|
No Groups Yet
|
||||||
|
</MyText>
|
||||||
|
<MyText
|
||||||
|
style={tw`text-slate-500 text-center mt-2 font-medium px-8`}
|
||||||
|
>
|
||||||
|
Start by creating your first product group using the button below.
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
groups.map((group, index) => (
|
||||||
|
<React.Fragment key={group.id}>
|
||||||
|
<GroupItem
|
||||||
|
group={group}
|
||||||
|
index={index}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onViewProducts={setViewProducts}
|
||||||
|
/>
|
||||||
|
{index < groups.length - 1 && (
|
||||||
|
<View style={tw`h-px bg-slate-200 w-full`} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global Floating Action Button */}
|
||||||
|
</ScrollView>
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={handleCreate}
|
||||||
|
activeOpacity={0.95}
|
||||||
|
style={{ position: "absolute", bottom: 32, right: 24, zIndex: 100 }}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["#1570EF", "#194185"]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-brand300`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={32} color="white" />
|
||||||
|
</LinearGradient>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-ui/app/(drawer)/product-tags/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Product Tags",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
apps/admin-ui/app/(drawer)/product-tags/index.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||||
|
import { TagMenu } from '@/src/components/TagMenu';
|
||||||
|
import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
|
||||||
|
|
||||||
|
interface TagItemProps {
|
||||||
|
item: Tag;
|
||||||
|
onDeleteSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagItem: React.FC<TagItemProps> = ({ item, onDeleteSuccess }) => (
|
||||||
|
<View style={tw`bg-white rounded-2xl p-6 mx-4 mb-4 shadow-sm border border-gray-100`}>
|
||||||
|
<View style={tw`flex-row justify-between items-start mb-4`}>
|
||||||
|
<View style={tw`flex-1 flex-row items-start`}>
|
||||||
|
{item.imageUrl && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.imageUrl }}
|
||||||
|
style={tw`w-16 h-16 rounded-lg mr-3 mt-1`}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800`}>{item.tagName}</MyText>
|
||||||
|
{item.tagDescription && (
|
||||||
|
<MyText style={tw`text-sm text-gray-600 mt-1`}>{item.tagDescription}</MyText>
|
||||||
|
)}
|
||||||
|
{item.isDashboardTag && (
|
||||||
|
<View style={tw`flex-row items-center mt-2`}>
|
||||||
|
<MaterialIcons name="dashboard" size={14} color="#16A34A" />
|
||||||
|
<MyText style={tw`text-xs text-green-700 ml-1`}>Dashboard Tag</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TagMenu tagId={item.id} onDeleteSuccess={onDeleteSuccess} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TagHeaderProps {
|
||||||
|
onAddNewTag: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
|
||||||
|
<View style={tw`flex-row justify-between items-center p-4 bg-white border-b border-gray-200`}>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-800`}>Product Tags</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onAddNewTag}
|
||||||
|
style={tw`bg-blue-500 px-4 py-2 rounded-lg flex-row items-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={20} color="white" />
|
||||||
|
<MyText style={tw`text-white font-medium ml-2`}>Add New Tag</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ProductTags() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: tagsData, isLoading, error, refetch } = useGetTags();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const tags = tagsData?.tags || [];
|
||||||
|
|
||||||
|
useManualRefresh(refetch);
|
||||||
|
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNewTag = () => {
|
||||||
|
router.push('/(drawer)/add-tag');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-gray-50 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-gray-500`}>Loading tags...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-gray-50 justify-center items-center p-4`}>
|
||||||
|
<MaterialIcons name="error" size={64} color="#EF4444" />
|
||||||
|
<MyText style={tw`text-red-500 text-lg font-semibold mt-4`}>Error</MyText>
|
||||||
|
<MyText style={tw`text-gray-600 text-center mt-2`}>{error?.message || 'Failed to load tags'}</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => refetch()}
|
||||||
|
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-medium`}>Retry</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-gray-50`}>
|
||||||
|
<MyFlatList
|
||||||
|
data={tags}
|
||||||
|
renderItem={({ item }) => <TagItem item={item} onDeleteSuccess={refetch} />}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} />}
|
||||||
|
contentContainerStyle={tw`pb-4`}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-12`}>
|
||||||
|
<MaterialIcons name="label-off" size={64} color="#D1D5DB" />
|
||||||
|
<MyText style={tw`text-gray-500 text-lg font-semibold mt-4`}>No tags yet</MyText>
|
||||||
|
<MyText style={tw`text-gray-400 text-center mt-2`}>Create your first product tag</MyText>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/admin-ui/app/(drawer)/products/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Products' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
apps/admin-ui/app/(drawer)/products/index.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { View, ScrollView, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui';
|
||||||
|
|
||||||
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { Product } from '@/src/api-hooks/product.api';
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
||||||
|
|
||||||
|
export default function Products() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
|
||||||
|
|
||||||
|
const toggleOutOfStockMutation = trpc.admin.product.toggleOutOfStock.useMutation();
|
||||||
|
|
||||||
|
useManualRefresh(refetch);
|
||||||
|
|
||||||
|
useMarkDataFetchers(() => {
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const products = productsData?.products || [];
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
return products.filter(product => {
|
||||||
|
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
|
||||||
|
const matchesFilter = activeFilter === 'all' ||
|
||||||
|
(activeFilter === 'in-stock' && !product.isOutOfStock) ||
|
||||||
|
(activeFilter === 'out-of-stock' && product.isOutOfStock);
|
||||||
|
|
||||||
|
return matchesSearch && matchesFilter;
|
||||||
|
});
|
||||||
|
}, [products, searchTerm, activeFilter]);
|
||||||
|
|
||||||
|
const handleEdit = (productId: number) => {
|
||||||
|
router.push(`/edit-product?id=${productId}` as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// const handleToggleStock = (product: any) => {
|
||||||
|
const handleToggleStock = (product: Pick<Product, 'id' | 'name' | 'isOutOfStock'>) => {
|
||||||
|
const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock';
|
||||||
|
Alert.alert(
|
||||||
|
'Update Stock Status',
|
||||||
|
`Are you sure you want to ${action} "${product.name}"?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Confirm',
|
||||||
|
onPress: () => {
|
||||||
|
toggleOutOfStockMutation.mutate({ id: product.id }, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Alert.alert('Success', data.message);
|
||||||
|
refetch(); // Refresh the list
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to update stock status');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDetails = (productId: number) => {
|
||||||
|
router.push(`/product-detail/${productId}` as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilterButton = ({ filter, label, count }: { filter: FilterType; label: string; count: number }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setActiveFilter(filter)}
|
||||||
|
style={tw`px-4 py-2 rounded-lg ${activeFilter === filter ? 'bg-blue-500' : 'bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`${activeFilter === filter ? 'text-white' : 'text-gray-700'} font-semibold`}>
|
||||||
|
{label} ({count})
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-gray-600`}>Loading products...</MyText>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-red-600`}>Error loading products</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => refetch()}
|
||||||
|
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStockCount = products.filter(p => !p.isOutOfStock).length;
|
||||||
|
const outOfStockCount = products.filter(p => p.isOutOfStock).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer>
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-6`}>
|
||||||
|
<MyText style={tw`text-2xl font-bold text-gray-800`}>Products</MyText>
|
||||||
|
<MyButton onPress={() => router.push('/add-product' as any)}>
|
||||||
|
Add Product
|
||||||
|
</MyButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<View style={tw`mb-4`}>
|
||||||
|
<SearchBar
|
||||||
|
value={searchTerm}
|
||||||
|
onChangeText={setSearchTerm}
|
||||||
|
onSearch={() => {}}
|
||||||
|
placeholder="Search products..."
|
||||||
|
containerStyle={tw`mb-0`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Filter Tabs */}
|
||||||
|
<View style={tw`flex-row gap-2 mb-6`}>
|
||||||
|
<FilterButton filter="all" label="All" count={products.length} />
|
||||||
|
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} />
|
||||||
|
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Products List */}
|
||||||
|
<ScrollView
|
||||||
|
style={tw`flex-1`}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filteredProducts.length === 0 ? (
|
||||||
|
<View style={tw`flex-1 justify-center items-center py-10`}>
|
||||||
|
<MaterialIcons name="inventory" size={64} color="#D1D5DB" />
|
||||||
|
<MyText style={tw`text-gray-500 text-center mt-4 text-lg`}>
|
||||||
|
{searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'}
|
||||||
|
</MyText>
|
||||||
|
<MyText style={tw`text-gray-400 text-center mt-2`}>
|
||||||
|
{searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'}
|
||||||
|
</MyText>
|
||||||
|
{(searchTerm || activeFilter !== 'all') && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setActiveFilter('all');
|
||||||
|
}}
|
||||||
|
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Clear Filters</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={tw`pb-4`}>
|
||||||
|
{filteredProducts.map(product => (
|
||||||
|
<View key={product.id} style={tw`bg-white rounded-2xl shadow-lg mb-4 overflow-hidden`}>
|
||||||
|
{/* Product Image */}
|
||||||
|
{product.images && product.images.length > 0 ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: product.images[0] }}
|
||||||
|
style={tw`w-full h-48`}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={tw`w-full h-48 bg-gray-200 justify-center items-center`}>
|
||||||
|
<MaterialIcons name="image" size={48} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<View style={tw`p-4`}>
|
||||||
|
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800 flex-1 mr-2`} numberOfLines={2}>
|
||||||
|
{product.name}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<View style={tw`w-3 h-3 rounded-full mr-1 ${product.isOutOfStock ? 'bg-red-500' : 'bg-green-500'}`} />
|
||||||
|
<MyText style={tw`text-sm ${product.isOutOfStock ? 'text-red-500' : 'text-green-500'} font-semibold`}>
|
||||||
|
{product.isOutOfStock ? 'Out' : 'In'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{product.shortDescription && (
|
||||||
|
<MyText style={tw`text-gray-600 mb-2`} numberOfLines={2}>
|
||||||
|
{product.shortDescription}
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-3`}>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-xl font-bold text-green-600`}>
|
||||||
|
₹{product.price}
|
||||||
|
</MyText>
|
||||||
|
{product.marketPrice && (
|
||||||
|
<MyText style={tw`text-sm text-gray-500 line-through`}>
|
||||||
|
₹{product.marketPrice}
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<View style={tw`flex-row gap-2`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleViewDetails(product.id)}
|
||||||
|
style={tw`flex-1 bg-gray-500 p-3 rounded-lg flex-row items-center justify-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="visibility" size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>View</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleEdit(product.id)}
|
||||||
|
style={tw`flex-1 bg-blue-500 p-3 rounded-lg flex-row items-center justify-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="edit" size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleToggleStock(product)}
|
||||||
|
style={tw`flex-1 ${product.isOutOfStock ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name={product.isOutOfStock ? "check-circle" : "block"} size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>
|
||||||
|
{product.isOutOfStock ? 'Stock' : 'Out'}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-ui/app/(drawer)/rebalance-orders/_layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function RebalanceOrdersLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Rebalance Orders",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
apps/admin-ui/app/(drawer)/rebalance-orders/index.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, TouchableOpacity, Alert, FlatList } from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { MyText, tw, MyTouchableOpacity, MyFlatList, BottomDialog } from 'common-ui';
|
||||||
|
import { trpc } from '../../../src/trpc-client';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
||||||
|
interface SlotItemProps {
|
||||||
|
item: any;
|
||||||
|
selectedSlots: number[];
|
||||||
|
toggleSlotSelection: (slotId: number) => void;
|
||||||
|
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>;
|
||||||
|
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SlotItemComponent: React.FC<SlotItemProps> = ({
|
||||||
|
item: slot,
|
||||||
|
selectedSlots,
|
||||||
|
toggleSlotSelection,
|
||||||
|
setDialogProducts,
|
||||||
|
setDialogOpen,
|
||||||
|
}) => {
|
||||||
|
const isSelected = selectedSlots.includes(slot.id);
|
||||||
|
const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || [];
|
||||||
|
const displayProducts = slotProducts.slice(0, 2).join(', ');
|
||||||
|
const isActive = slot.isActive;
|
||||||
|
const statusColor = isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700';
|
||||||
|
const statusText = isActive ? 'Active' : 'Inactive';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => toggleSlotSelection(slot.id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={tw`bg-white p-5 mb-4 rounded-3xl shadow-sm border border-gray-100`}
|
||||||
|
>
|
||||||
|
{/* Header: Checkbox, ID and Status */}
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-4`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<TouchableOpacity onPress={() => toggleSlotSelection(slot.id)} style={tw`mr-3`}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={isSelected ? "checkbox-marked" : "checkbox-blank-outline"}
|
||||||
|
size={24}
|
||||||
|
color={isSelected ? "#F83758" : "#6b7280"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={tw`w-10 h-10 bg-pink2 rounded-full items-center justify-center mr-3`}>
|
||||||
|
<MaterialCommunityIcons name="calendar-clock" size={20} color="#F83758" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-900`}>Slot #{slot.id}</MyText>
|
||||||
|
<MyText style={tw`text-xs text-gray-500`}>ID: {slot.id}</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<View style={tw`px-3 py-1 rounded-full ${statusColor.split(' ')[0]}`}>
|
||||||
|
<MyText style={tw`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View style={tw`h-[1px] bg-gray-100 mb-4`} />
|
||||||
|
|
||||||
|
{/* Details Grid */}
|
||||||
|
<View style={tw`flex-row flex-wrap`}>
|
||||||
|
{/* Delivery Time */}
|
||||||
|
<View style={tw`w-1/2 mb-4 pr-2`}>
|
||||||
|
<View style={tw`flex-row items-center mb-1`}>
|
||||||
|
<MaterialCommunityIcons name="truck-delivery-outline" size={14} color="#6b7280" style={tw`mr-1`} />
|
||||||
|
<MyText style={tw`text-xs text-gray-500 uppercase font-semibold tracking-wider`}>Delivery</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-800`}>
|
||||||
|
{dayjs(slot.deliveryTime).format('DD MMM, h:mm A')}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Freeze Time */}
|
||||||
|
<View style={tw`w-1/2 mb-4 pl-2`}>
|
||||||
|
<View style={tw`flex-row items-center mb-1`}>
|
||||||
|
<MaterialCommunityIcons name="snowflake" size={14} color="#6b7280" style={tw`mr-1`} />
|
||||||
|
<MyText style={tw`text-xs text-gray-500 uppercase font-semibold tracking-wider`}>Freeze</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-800`}>
|
||||||
|
{dayjs(slot.freezeTime).format('DD MMM, h:mm A')}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Products */}
|
||||||
|
{slotProducts.length > 0 ? (
|
||||||
|
<View style={tw`bg-gray-50 p-3 rounded-xl mt-1`}>
|
||||||
|
<View style={tw`flex-row items-start`}>
|
||||||
|
<MaterialCommunityIcons name="basket-outline" size={16} color="#4b5563" style={tw`mr-2 mt-0.5`} />
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-xs text-gray-500 mb-0.5`}>Products</MyText>
|
||||||
|
<View style={tw`flex-row items-center flex-wrap`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-800 leading-5`}>{displayProducts}</MyText>
|
||||||
|
{slotProducts.length > 2 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setDialogProducts(slotProducts);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-sm text-pink1 font-semibold ml-1`}>
|
||||||
|
+{slotProducts.length - 2} more
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RebalanceOrders() {
|
||||||
|
const [selectedSlots, setSelectedSlots] = useState<number[]>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogProducts, setDialogProducts] = useState<any[]>([]);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const { data: slotsData, isLoading, refetch: refetchSlots } = trpc.admin.slots.getAll.useQuery();
|
||||||
|
|
||||||
|
const upcomingSlots = slotsData?.slots?.filter(slot => dayjs(slot.deliveryTime).isAfter(dayjs())) || [];
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetchSlots();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutate: rebalanceSlots } = trpc.admin.order.rebalanceSlots.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchSlots();
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
Alert.alert("Rebalance Complete", "Slots have been rebalanced.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSlotSelection = (slotId: number) => {
|
||||||
|
setSelectedSlots(prev =>
|
||||||
|
prev.includes(slotId)
|
||||||
|
? prev.filter(id => id !== slotId)
|
||||||
|
: [...prev, slotId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRebalance = () => {
|
||||||
|
Alert.alert("Rebalancing...", "Please wait while we rebalance the selected slots.", [{ text: "OK" }]);
|
||||||
|
rebalanceSlots({ slotIds: selectedSlots });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<MyText>Loading slots...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-white relative`}>
|
||||||
|
<View style={tw`p-4 flex-1`}>
|
||||||
|
<MyText style={tw`text-xl font-bold text-gray-900 mb-4`}>Rebalance Upcoming Slots</MyText>
|
||||||
|
{upcomingSlots.length === 0 ? (
|
||||||
|
<View style={tw`flex-1 justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-lg text-gray-600`}>No upcoming slots available for rebalancing.</MyText>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<MyFlatList
|
||||||
|
data={upcomingSlots}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<SlotItemComponent
|
||||||
|
item={item}
|
||||||
|
selectedSlots={selectedSlots}
|
||||||
|
toggleSlotSelection={toggleSlotSelection}
|
||||||
|
setDialogProducts={setDialogProducts}
|
||||||
|
setDialogOpen={setDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={tw`pb-24 flex-1`} // Space for floating button
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Floating Rebalance Button */}
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={handleRebalance}
|
||||||
|
disabled={selectedSlots.length === 0}
|
||||||
|
activeOpacity={0.95}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 32,
|
||||||
|
right: 24,
|
||||||
|
zIndex: 100,
|
||||||
|
opacity: selectedSlots.length === 0 ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={selectedSlots.length === 0 ? ['#9CA3AF', '#6B7280'] : ['#F83758', '#E91E63']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-pink300`}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="refresh" size={32} color="white" />
|
||||||
|
</LinearGradient>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
|
||||||
|
{/* Products Dialog */}
|
||||||
|
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||||
|
<View style={tw`p-4`}>
|
||||||
|
<MyText style={tw`text-lg font-bold mb-4`}>All Products</MyText>
|
||||||
|
<FlatList
|
||||||
|
data={dialogProducts}
|
||||||
|
keyExtractor={(item, index) => index.toString()}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={tw`py-2 border-b border-gray-200`}>
|
||||||
|
<MyText style={tw`text-base`}>{item}</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
style={tw`max-h-80`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/admin-ui/app/(drawer)/slots/_layout.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'Slots' }} />
|
||||||
|
<Stack.Screen name="slot-details" options={{ title: 'Slot Details' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
apps/admin-ui/app/(drawer)/slots/index.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { View, TouchableOpacity, FlatList } from 'react-native';
|
||||||
|
import { AppContainer, MyText, tw, MyFlatList , BottomDialog, MyTouchableOpacity } from 'common-ui';
|
||||||
|
import { trpc } from '../../../src/trpc-client';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
||||||
|
interface SlotItemProps {
|
||||||
|
item: any;
|
||||||
|
router: any;
|
||||||
|
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>;
|
||||||
|
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SlotItemComponent: React.FC<SlotItemProps> = ({
|
||||||
|
item: slot,
|
||||||
|
router,
|
||||||
|
setDialogProducts,
|
||||||
|
setDialogOpen,
|
||||||
|
}) => {
|
||||||
|
const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || [];
|
||||||
|
const displayProducts = slotProducts.slice(0, 2).join(', ');
|
||||||
|
|
||||||
|
const isActive = slot.isActive;
|
||||||
|
const statusColor = isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700';
|
||||||
|
const statusText = isActive ? 'Active' : 'Inactive';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push(`/(drawer)/slots/slot-details?slotId=${slot.id}`)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={tw`bg-white p-5 mb-4 rounded-3xl shadow-sm border border-gray-100`}>
|
||||||
|
{/* Header: ID and Status */}
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-4`}>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<View style={tw`w-10 h-10 bg-pink2 rounded-full items-center justify-center mr-3`}>
|
||||||
|
<MaterialCommunityIcons name="calendar-clock" size={20} color="#F83758" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-900`}>Slot #{slot.id}</MyText>
|
||||||
|
<MyText style={tw`text-xs text-gray-500`}>ID: {slot.id}</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push(`/edit-slot/${slot.id}` as any)}
|
||||||
|
style={tw`px-3 py-1 rounded-full bg-pink2 mr-2`}
|
||||||
|
>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<MaterialCommunityIcons name="pencil" size={12} color="#F83758" style={tw`mr-1`} />
|
||||||
|
<MyText style={tw`text-xs font-bold text-pink1`}>Edit</MyText>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={tw`px-3 py-1 rounded-full ${statusColor.split(' ')[0]}`}>
|
||||||
|
<MyText style={tw`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View style={tw`h-[1px] bg-gray-100 mb-4`} />
|
||||||
|
|
||||||
|
{/* Details Grid */}
|
||||||
|
<View style={tw`flex-row flex-wrap`}>
|
||||||
|
{/* Delivery Time */}
|
||||||
|
<View style={tw`w-1/2 mb-4 pr-2`}>
|
||||||
|
<View style={tw`flex-row items-center mb-1`}>
|
||||||
|
<MaterialCommunityIcons name="truck-delivery-outline" size={14} color="#6b7280" style={tw`mr-1`} />
|
||||||
|
<MyText style={tw`text-xs text-gray-500 uppercase font-semibold tracking-wider`}>Delivery</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-800`}>
|
||||||
|
{dayjs(slot.deliveryTime).format('DD MMM, h:mm A')}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Freeze Time */}
|
||||||
|
<View style={tw`w-1/2 mb-4 pl-2`}>
|
||||||
|
<View style={tw`flex-row items-center mb-1`}>
|
||||||
|
<MaterialCommunityIcons name="snowflake" size={14} color="#6b7280" style={tw`mr-1`} />
|
||||||
|
<MyText style={tw`text-xs text-gray-500 uppercase font-semibold tracking-wider`}>Freeze</MyText>
|
||||||
|
</View>
|
||||||
|
<MyText style={tw`text-sm font-medium text-gray-800`}>
|
||||||
|
{dayjs(slot.freezeTime).format('DD MMM, h:mm A')}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Products */}
|
||||||
|
{slotProducts.length > 0 ? (
|
||||||
|
<View style={tw`bg-gray-50 p-3 rounded-xl mt-1`}>
|
||||||
|
<View style={tw`flex-row items-start`}>
|
||||||
|
<MaterialCommunityIcons name="basket-outline" size={16} color="#4b5563" style={tw`mr-2 mt-0.5`} />
|
||||||
|
<View style={tw`flex-1`}>
|
||||||
|
<MyText style={tw`text-xs text-gray-500 mb-0.5`}>Products</MyText>
|
||||||
|
<View style={tw`flex-row items-center flex-wrap`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-800 leading-5`}>{displayProducts}</MyText>
|
||||||
|
{slotProducts.length > 2 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setDialogProducts(slotProducts);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-sm text-pink1 font-semibold ml-1`}>
|
||||||
|
+{slotProducts.length - 2} more
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Slots() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: slotsData, isLoading, refetch } = trpc.admin.slots.getAll.useQuery();
|
||||||
|
|
||||||
|
const slots = slotsData?.slots || [];
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogProducts, setDialogProducts] = useState<any[]>([]);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||||
|
<MyText>Loading slots...</MyText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-white relative`}>
|
||||||
|
<MyFlatList
|
||||||
|
data={slots}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<SlotItemComponent
|
||||||
|
item={item}
|
||||||
|
router={router}
|
||||||
|
setDialogProducts={setDialogProducts}
|
||||||
|
setDialogOpen={setDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={tw`p-4`}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* FAB for Add New Slot */}
|
||||||
|
<MyTouchableOpacity
|
||||||
|
onPress={() => router.push('/add-slot' as any)}
|
||||||
|
activeOpacity={0.95}
|
||||||
|
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F83758', '#E91E63']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-pink300`}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="plus" size={32} color="white" />
|
||||||
|
</LinearGradient>
|
||||||
|
</MyTouchableOpacity>
|
||||||
|
|
||||||
|
{/* Products Dialog */}
|
||||||
|
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||||
|
<View style={tw`p-4`}>
|
||||||
|
<MyText style={tw`text-lg font-bold mb-4`}>All Products</MyText>
|
||||||
|
<FlatList
|
||||||
|
data={dialogProducts}
|
||||||
|
keyExtractor={(item, index) => index.toString()}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={tw`py-2 border-b border-gray-200`}>
|
||||||
|
<MyText style={tw`text-base`}>{item}</MyText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
style={tw`max-h-80`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</BottomDialog>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||