diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/ProductController.java b/backend/src/main/java/com/rit/canteen/sales/controller/ProductController.java index 332a51ea..7ad3fec4 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/ProductController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/ProductController.java @@ -33,6 +33,11 @@ public class ProductController { return productRepository.findAllWithStalls(org.springframework.data.domain.PageRequest.of(page, size)); } + @GetMapping("/drafts") + public List getDraftProducts() { + return productRepository.findByIsDraftTrue(); + } + @GetMapping("/category/{categoryName}") public List getProductsByCategory(@PathVariable String categoryName) { return productRepository.findByCategory(categoryName); @@ -172,4 +177,43 @@ public class ProductController { } return ResponseEntity.ok().build(); } + + @PostMapping("/{id}/publish") + @org.springframework.transaction.annotation.Transactional + public ResponseEntity publishProduct(@PathVariable Long id, @RequestBody Product productDetails) { + return productRepository.findById(id) + .map(product -> { + // Generate new PRD ID + String newProductId = "PRD-" + String.format("%04d", new java.util.Random().nextInt(10000)); + + // Update all details first + product.setName(productDetails.getName()); + product.setProductId(newProductId); + product.setCategory(productDetails.getCategory()); + product.setDescription(productDetails.getDescription()); + product.setBasePrice(productDetails.getBasePrice()); + product.setPrice(productDetails.getPrice()); + product.setOfferPrice(productDetails.getOfferPrice()); + product.setCounter(productDetails.getCounter()); + product.setTag(productDetails.getTag()); + product.setBarcode(productDetails.getBarcode()); + product.setImageData(productDetails.getImageData()); + product.setStock(productDetails.getStock()); + + product.getSessions().clear(); + if (productDetails.getSessions() != null) { + product.getSessions().addAll(productDetails.getSessions()); + } + + // Finalize + product.setDraft(false); + product.setActive(true); + + Product updated = productRepository.save(product); + updateStallAssociations(updated, productDetails.getStalls()); + + return ResponseEntity.ok(productRepository.findById(updated.getId()).orElse(updated)); + }) + .orElse(ResponseEntity.notFound().build()); + } } diff --git a/backend/src/main/java/com/rit/canteen/sales/model/Product.java b/backend/src/main/java/com/rit/canteen/sales/model/Product.java index 0b4ec5a2..1605e9c4 100644 --- a/backend/src/main/java/com/rit/canteen/sales/model/Product.java +++ b/backend/src/main/java/com/rit/canteen/sales/model/Product.java @@ -63,6 +63,8 @@ public class Product { private Integer stock = 0; + private Boolean isDraft = false; + @ManyToMany(mappedBy = "products", fetch = FetchType.EAGER) @JsonIgnoreProperties({"products", "baseItems", "sessions", "imageData"}) private List stalls = new ArrayList<>(); @@ -141,4 +143,7 @@ public class Product { public List getStalls() { return stalls; } public void setStalls(List stalls) { this.stalls = stalls; } + + public boolean isDraft() { return isDraft != null && isDraft; } + public void setDraft(Boolean draft) { isDraft = draft; } } diff --git a/backend/src/main/java/com/rit/canteen/sales/repository/ProductRepository.java b/backend/src/main/java/com/rit/canteen/sales/repository/ProductRepository.java index bdccf45f..351dc5d2 100644 --- a/backend/src/main/java/com/rit/canteen/sales/repository/ProductRepository.java +++ b/backend/src/main/java/com/rit/canteen/sales/repository/ProductRepository.java @@ -22,6 +22,8 @@ public interface ProductRepository extends JpaRepository { @Query("SELECT DISTINCT p.category FROM Product p WHERE p.category IS NOT NULL") List findDistinctCategories(); + + List findByIsDraftTrue(); boolean existsByNameAndCategory(String name, String category); @org.springframework.data.jpa.repository.Modifying diff --git a/backend/src/main/java/com/rit/canteen/sales/service/PurchaseService.java b/backend/src/main/java/com/rit/canteen/sales/service/PurchaseService.java index 3afbaca7..daa2f3d9 100644 --- a/backend/src/main/java/com/rit/canteen/sales/service/PurchaseService.java +++ b/backend/src/main/java/com/rit/canteen/sales/service/PurchaseService.java @@ -1,9 +1,12 @@ package com.rit.canteen.sales.service; +import com.rit.canteen.sales.model.Product; import com.rit.canteen.sales.model.PurchaseOrder; +import com.rit.canteen.sales.model.PurchaseOrderItem; import com.rit.canteen.sales.model.Vendor; import com.rit.canteen.sales.repository.PurchaseOrderRepository; import com.rit.canteen.sales.repository.VendorRepository; +import com.rit.canteen.sales.repository.ProductRepository; import com.rit.canteen.sales.repository.PurchaseOrderHistoryRepository; import com.rit.canteen.sales.model.PurchaseOrderHistory; import org.springframework.beans.factory.annotation.Autowired; @@ -30,6 +33,9 @@ public class PurchaseService { @Autowired private PurchaseOrderHistoryRepository historyRepository; + @Autowired + private ProductRepository productRepository; + public List getAllOrders() { return purchaseOrderRepository.findAll(); } @@ -60,6 +66,11 @@ public class PurchaseService { PurchaseOrder savedOrder = purchaseOrderRepository.save(order); + // Handle Product Import if status is RECEIVED + if ("RECEIVED".equals(savedOrder.getStatus())) { + importToNewArrivals(savedOrder); + } + // Update vendor statistics (only for NEW orders to avoid double counting, // OR we should adjust the difference for updates - but user mostly wants new orders tracked) if (order.getId() == null && savedOrder.getVendor() != null && savedOrder.getVendor().getId() != null) { @@ -136,4 +147,22 @@ public class PurchaseService { return summary; } + + private void importToNewArrivals(PurchaseOrder order) { + if (order.getItems() == null) return; + + for (PurchaseOrderItem item : order.getItems()) { + Product draftProduct = new Product(); + draftProduct.setName(item.getProductName()); + draftProduct.setStock(item.getQuantity() != null ? item.getQuantity().intValue() : 0); + draftProduct.setBasePrice(item.getRate()); + draftProduct.setDraft(true); + draftProduct.setActive(false); // Not live yet + + // Set a unique product ID if possible + draftProduct.setProductId("DRAFT-" + String.format("%04d", new java.util.Random().nextInt(10000)) + "-" + item.getId()); + + productRepository.save(draftProduct); + } + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd6c1f41..2fb8433a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import Bills from './pages/Bills.tsx'; import PurchaseSummary from './pages/PurchaseSummary.tsx'; import IntentDashboard from './pages/IntentDashboard.tsx'; import IntentList from './pages/IntentList.tsx'; +import NewArrivals from './pages/NewArrivals.tsx'; import Reports from './pages/Reports.tsx'; import Feedback from './pages/Feedback.tsx'; @@ -77,6 +78,7 @@ function App() { } /> {/* Inventory */} + } /> } /> } /> } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6056277b..2958f81f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -90,6 +90,7 @@ const menuItems: MenuItem[] = [ title: 'Inventory', icon: ShoppingCart, subMenu: [ + { title: 'New Arrivals', path: '/inventory/new-arrivals' }, { title: 'Base Items', path: '/inventory/base' }, { title: 'Products', path: '/inventory/products' } ] diff --git a/frontend/src/pages/NewArrivals.tsx b/frontend/src/pages/NewArrivals.tsx new file mode 100644 index 00000000..848f2a28 --- /dev/null +++ b/frontend/src/pages/NewArrivals.tsx @@ -0,0 +1,437 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + X, Search, RefreshCw, Edit2, Package, Image as ImageIcon, + Clock, Check, Trash2, Rocket, ArrowRight, AlertCircle +} from 'lucide-react'; + +interface ProductSession { + id?: number; + dayOfWeek: string; + active: boolean; + startTime: string; + endTime: string; +} + +interface Product { + id?: number; + productId: string; + name: string; + category: string; + description: string; + basePrice: number; + price: number; + offerPrice: number; + discountPercent: number; + discountAmount: number; + counter: string; + tag: string; + parcelCharges: number; + barcode: string; + attributesOptional: boolean; + veg: boolean; + hasAllergy: boolean; + parcelNotAllowed: boolean; + sessionOptional: boolean; + sessions: ProductSession[]; + imageData: string; + active: boolean; + stock: number; + stalls?: { id: number; name: string }[]; +} + +interface Stall { + id: number; + name: string; +} + +interface BaseItem { + id: number; + name: string; +} + +const DAYS = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; + +const getDefaultSessions = (): ProductSession[] => + DAYS.map(day => ({ dayOfWeek: day, active: true, startTime: '00:00', endTime: '23:59' })); + +const NewArrivals: React.FC = () => { + const [drafts, setDrafts] = useState([]); + const [baseItems, setBaseItems] = useState([]); + const [allStalls, setAllStalls] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [showSessionModal, setShowSessionModal] = useState(false); + const [editingDraft, setEditingDraft] = useState(null); + const [formData, setFormData] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const fileInputRef = useRef(null); + + useEffect(() => { + fetchDrafts(); + fetchMetadata(); + }, []); + + const fetchDrafts = async () => { + setLoading(true); + try { + const response = await fetch('/api/products/drafts'); + if (response.ok) { + const data = await response.json(); + setDrafts(data); + } + } catch (error) { + console.error('Error fetching drafts:', error); + } finally { + setLoading(false); + } + }; + + const fetchMetadata = async () => { + try { + const [stallsRes, itemsRes] = await Promise.all([ + fetch('/api/stalls'), + fetch('/api/base-items?size=100') + ]); + setAllStalls(await stallsRes.json()); + const itemsData = await itemsRes.json(); + setBaseItems(itemsData.content || itemsData); + } catch (error) { + console.error('Error fetching metadata:', error); + } + }; + + const handleEdit = (product: Product) => { + setEditingDraft(product); + setFormData({ + ...product, + sessions: product.sessions && product.sessions.length > 0 ? product.sessions : getDefaultSessions() + }); + setShowModal(true); + }; + + const handlePublish = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData || !formData.id) return; + + setIsSaving(true); + try { + const response = await fetch(`/api/products/${formData.id}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + if (response.ok) { + setShowModal(false); + setEditingDraft(null); + setFormData(null); + fetchDrafts(); + } + } catch (error) { + console.error('Error publishing product:', error); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async (id: number) => { + if (!window.confirm('Delete this draft product?')) return; + try { + await fetch(`/api/products/${id}`, { method: 'DELETE' }); + fetchDrafts(); + } catch (error) { + console.error('Error deleting draft:', error); + } + }; + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file && formData) { + const reader = new FileReader(); + reader.onloadend = () => { + setFormData({ ...formData, imageData: reader.result as string }); + }; + reader.readAsDataURL(file); + } + }; + + const updateSession = (index: number, updates: Partial) => { + if (!formData) return; + const newSessions = [...formData.sessions]; + newSessions[index] = { ...newSessions[index], ...updates }; + setFormData({ ...formData, sessions: newSessions }); + }; + + return ( +
+
+
+
+ +
+
+

New Arrivals

+

Configure and publish incoming procurement stock to your live store

+
+
+ +
+ +
+ {loading ? ( +
+ +

Fetching Drafts...

+
+ ) : drafts.length === 0 ? ( +
+
+ +
+

Clean Slate

+

All incoming stock has been processed. New drafts will appear here once you mark vendor orders as received.

+
+ ) : ( +
+ {drafts.map((draft) => ( +
+
+ {draft.imageData ? ( + {draft.name} + ) : ( +
+ + No Image +
+ )} +
+ Draft Arrival +
+
+ +
+

{draft.name}

+
+ + {draft.stock} Units Arrived +
+ +
+
+ Procurement Rate + ₹{draft.basePrice || 0} +
+
+ + +
+
+
+
+ ))} +
+ )} +
+ + {/* Draft Configuration Modal */} + {showModal && formData && ( +
+
+
+
+
+ +
+
+

Finalize Product

+

Configure your latest arrival for the live menu

+
+
+ +
+ +
+
+
+
+ + setFormData({ ...formData, name: e.target.value })} className="w-full px-5 py-4 bg-white border-2 border-gray-50 focus:border-[#231651]/20 rounded-2xl text-sm font-bold outline-none transition-all" /> +
+
+ + +
+
+ + setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} className="w-full px-5 py-4 bg-[#231651]/5 border-2 border-transparent focus:border-[#231651]/20 rounded-2xl text-lg font-black text-[#231651] outline-none transition-all" /> +
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + setFormData({ ...formData, barcode: e.target.value })} className="w-full px-5 py-4 bg-white border-2 border-gray-50 focus:border-[#231651]/20 rounded-2xl text-sm font-bold outline-none" placeholder="Scan or type..." /> +
+
+ +
+

Configuration Required

+

Associate this item with a stall and session before publishing to the ordering site.

+
+
+
+ +
+
+ +
fileInputRef.current?.click()} + className="border-2 border-dashed border-[#e2e8f0] rounded-3xl p-6 flex flex-col items-center justify-center text-[#94a3b8] hover:border-[#231651]/30 hover:bg-gray-50 transition-all cursor-pointer h-64 overflow-hidden shadow-inner bg-gray-50" + > + {formData.imageData ? ( + Preview + ) : ( + <> +
+ +
+

Upload Product Image

+

PNG, JPG, HEIC up to 2MB

+ + )} +
+ +
+
+
+ +
+
+
+

Availability Settings

+ +
+

Control which times of day this product is visible to customers.

+ {formData.sessionOptional && ( + + )} +
+
+
+ +
+
+
+
+
+ )} + + {/* Session Modal (Reused from Products.tsx) */} + {showSessionModal && formData && ( +
+
+
+

Weekly Availability Schedule

+
+ {formData.sessions.map((session, index) => ( +
+
+ {session.dayOfWeek} +
updateSession(index, { active: !session.active })} + className={`w-7 h-7 rounded-xl flex items-center justify-center cursor-pointer transition-all ${session.active ? 'bg-[#231651]' : 'border-2 border-gray-200 bg-white'}`} + > + {session.active && } +
+
+ +
+
+ + updateSession(index, { startTime: e.target.value })} + className="w-full bg-white border border-[#e2e8f0] rounded-xl px-4 py-3 text-sm font-bold text-[#1e293b] outline-none shadow-sm" + /> +
+
+ + updateSession(index, { endTime: e.target.value })} + className="w-full bg-white border border-[#e2e8f0] rounded-xl px-4 py-3 text-sm font-bold text-[#1e293b] outline-none shadow-sm" + /> +
+
+
+ ))} +
+
+ +
+
+
+
+ )} + +