This commit is contained in:
shafi54 2026-01-24 00:13:15 +05:30
commit 524acbd1a6
658 changed files with 232913 additions and 0 deletions

133
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"expo": {}
}

View 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.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
{
"devices": []
}

View file

@ -0,0 +1,4 @@
{
"dependencies": "091948e86692e0cce7744b6b0543448538c3125a",
"devDependencies": "b3b38265f32b99a8299270a292f38ca26288d53d"
}

14
apps/admin-ui/.expo/types/router.d.ts vendored Normal file

File diff suppressed because one or more lines are too long

6
apps/admin-ui/.gitignore vendored Executable file
View 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
View 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
View 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"
}
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &ldquo;{searchQuery}&rdquo;</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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: 'Create Banner' }} />
</Stack>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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 Banner' }} />
</Stack>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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: 'Delivery Sequences' }} />
</Stack>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

Some files were not shown because too many files have changed in this diff Show more