diff --git a/README.md b/README.md index 50e618f..6acdbda 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,42 @@ BEGIN END; $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; +-- Return function (moves items back from a committee to main inventory) +CREATE OR REPLACE FUNCTION return_item( + p_committee_id UUID, + p_item_id UUID, + p_quantity INTEGER +) RETURNS void AS $$ +BEGIN + IF (SELECT quantity FROM committee_inventory + WHERE committee_id = p_committee_id + AND item_id = p_item_id + AND user_id = auth.uid()) < p_quantity THEN + RAISE EXCEPTION 'Committee does not have enough of this item to return'; + END IF; + + UPDATE main_inventory + SET quantity = quantity + p_quantity + WHERE item_id = p_item_id AND user_id = auth.uid(); + + UPDATE committee_inventory + SET quantity = quantity - p_quantity + WHERE committee_id = p_committee_id + AND item_id = p_item_id + AND user_id = auth.uid(); + + INSERT INTO dispatch_log (committee_id, item_id, quantity, item_name, committee_name) + VALUES ( + p_committee_id, + p_item_id, + -p_quantity, + (SELECT name FROM items WHERE id = p_item_id), + (SELECT name FROM committees WHERE id = p_committee_id) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Account deletion (lets users delete their own account from the app) -- Fulfill request (requester: confirms receipt, moves to committee inventory) CREATE OR REPLACE FUNCTION fulfill_request( p_request_id UUID diff --git a/mun-logi-inventory/SQLCode b/mun-logi-inventory/SQLCode new file mode 100644 index 0000000..a66ba3b --- /dev/null +++ b/mun-logi-inventory/SQLCode @@ -0,0 +1,171 @@ +-- Tables (each row is scoped to the signed-in user) +CREATE TABLE items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID DEFAULT auth.uid(), + name TEXT NOT NULL, + image_url TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(name, user_id) +); + +CREATE TABLE main_inventory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID DEFAULT auth.uid(), + item_id UUID REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL DEFAULT 0, + UNIQUE(item_id, user_id) +); + +CREATE TABLE committees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID DEFAULT auth.uid(), + name TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(name, user_id) +); + +CREATE TABLE committee_inventory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID DEFAULT auth.uid(), + committee_id UUID REFERENCES committees(id) ON DELETE CASCADE, + item_id UUID REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL DEFAULT 0, + UNIQUE(committee_id, item_id, user_id) +); + +CREATE TABLE dispatch_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID DEFAULT auth.uid(), + committee_id UUID, + item_id UUID, + quantity INTEGER NOT NULL, + dispatched_at TIMESTAMPTZ DEFAULT now(), + note TEXT, + item_name TEXT, + committee_name TEXT +); + +-- Row-level security (users can only access their own data) +ALTER TABLE items ENABLE ROW LEVEL SECURITY; +ALTER TABLE main_inventory ENABLE ROW LEVEL SECURITY; +ALTER TABLE committees ENABLE ROW LEVEL SECURITY; +ALTER TABLE committee_inventory ENABLE ROW LEVEL SECURITY; +ALTER TABLE dispatch_log ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "User owns data" ON items FOR ALL TO authenticated + USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); +CREATE POLICY "User owns data" ON main_inventory FOR ALL TO authenticated + USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); +CREATE POLICY "User owns data" ON committees FOR ALL TO authenticated + USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); +CREATE POLICY "User owns data" ON committee_inventory FOR ALL TO authenticated + USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); +CREATE POLICY "User owns data" ON dispatch_log FOR ALL TO authenticated + USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); + +-- Default inventory created automatically for every new user on sign-up +CREATE OR REPLACE FUNCTION public.create_default_inventory() +RETURNS trigger AS $$ +DECLARE + item_names TEXT[] := ARRAY[ + 'Folder', 'Notepad', 'Pen', 'ID & Lanyard', 'Placard', + 'Sticker', 'Badge', 'Markers', 'Extension Boards', 'Water Bottles' + ]; + item_name TEXT; + new_item_id UUID; +BEGIN + FOREACH item_name IN ARRAY item_names LOOP + INSERT INTO public.items (user_id, name) + VALUES (NEW.id, item_name) RETURNING id INTO new_item_id; + INSERT INTO public.main_inventory (user_id, item_id, quantity) + VALUES (NEW.id, new_item_id, 0); + END LOOP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.create_default_inventory(); + +-- Dispatch function (moves items from main inventory to a committee) +CREATE OR REPLACE FUNCTION dispatch_item( + p_committee_id UUID, + p_item_id UUID, + p_quantity INTEGER +) RETURNS void AS $$ +BEGIN + IF (SELECT quantity FROM main_inventory WHERE item_id = p_item_id AND user_id = auth.uid()) < p_quantity THEN + RAISE EXCEPTION 'Insufficient stock in main inventory'; + END IF; + + UPDATE main_inventory + SET quantity = quantity - p_quantity + WHERE item_id = p_item_id AND user_id = auth.uid(); + + INSERT INTO committee_inventory (committee_id, item_id, quantity) + VALUES (p_committee_id, p_item_id, p_quantity) + ON CONFLICT (committee_id, item_id, user_id) + DO UPDATE SET quantity = committee_inventory.quantity + p_quantity; + + INSERT INTO dispatch_log (committee_id, item_id, quantity, item_name, committee_name) + VALUES ( + p_committee_id, + p_item_id, + p_quantity, + (SELECT name FROM items WHERE id = p_item_id), + (SELECT name FROM committees WHERE id = p_committee_id) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Return function (moves items back from a committee to main inventory) +CREATE OR REPLACE FUNCTION return_item( + p_committee_id UUID, + p_item_id UUID, + p_quantity INTEGER +) RETURNS void AS $$ +BEGIN + -- Validate the committee has enough of this item to return + IF (SELECT quantity FROM committee_inventory + WHERE committee_id = p_committee_id + AND item_id = p_item_id + AND user_id = auth.uid()) < p_quantity THEN + RAISE EXCEPTION 'Committee does not have enough of this item to return'; + END IF; + + -- Add back to main inventory + UPDATE main_inventory + SET quantity = quantity + p_quantity + WHERE item_id = p_item_id AND user_id = auth.uid(); + + -- Subtract from committee inventory + UPDATE committee_inventory + SET quantity = quantity - p_quantity + WHERE committee_id = p_committee_id + AND item_id = p_item_id + AND user_id = auth.uid(); + + -- Log the return as a negative-quantity dispatch entry + INSERT INTO dispatch_log (committee_id, item_id, quantity, item_name, committee_name) + VALUES ( + p_committee_id, + p_item_id, + -p_quantity, + (SELECT name FROM items WHERE id = p_item_id), + (SELECT name FROM committees WHERE id = p_committee_id) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Account deletion (lets users delete their own account from the app) +CREATE OR REPLACE FUNCTION public.delete_own_account() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = auth, public +AS $$ +BEGIN + DELETE FROM auth.users WHERE id = auth.uid(); +END; +$$; \ No newline at end of file diff --git a/mun-logi-inventory/src/components/ItemCard.jsx b/mun-logi-inventory/src/components/ItemCard.jsx index 2a89c55..3dd6f9c 100644 --- a/mun-logi-inventory/src/components/ItemCard.jsx +++ b/mun-logi-inventory/src/components/ItemCard.jsx @@ -68,6 +68,25 @@ export default function ItemCard({ item, quantity, onDispatch, onReturn, onUpdat )} + {/* Action buttons */} +
+ {showDispatch && ( + + )} + {showReturn && quantity > 0 && ( + + )} +
{/* Dispatch & Return buttons */} {(showDispatch || showReturn) && (
diff --git a/mun-logi-inventory/src/components/ReturnModal.jsx b/mun-logi-inventory/src/components/ReturnModal.jsx new file mode 100644 index 0000000..cdf6232 --- /dev/null +++ b/mun-logi-inventory/src/components/ReturnModal.jsx @@ -0,0 +1,73 @@ +import { useState } from 'react' + +export default function ReturnModal({ itemName, committeeName, maxQuantity, onConfirm, onClose, isLoading, error }) { + const [quantity, setQuantity] = useState(maxQuantity) + + return ( +
+
e.stopPropagation()} + > +

Return Items

+

+ Returning {itemName} from{' '} + {committeeName} back to inventory +

+ + {error && ( +
+ {error} +
+ )} + + {/* Quantity selector */} +
+ + setQuantity(Math.max(1, Math.min(maxQuantity, parseInt(e.target.value) || 1)))} + className="flex-1 bg-gray-50 border border-gray-200 text-gray-800 text-center text-xl font-bold font-montserrat py-2.5 rounded-lg outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200" + /> + +
+ +

+ Max returnable: {maxQuantity} +

+ +
+ + +
+
+
+ ) +} diff --git a/mun-logi-inventory/src/pages/CommitteePage.jsx b/mun-logi-inventory/src/pages/CommitteePage.jsx index d72379a..b44c3de 100644 --- a/mun-logi-inventory/src/pages/CommitteePage.jsx +++ b/mun-logi-inventory/src/pages/CommitteePage.jsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { supabase } from '../lib/supabase' import ItemCard from '../components/ItemCard' import DispatchModal from '../components/DispatchModal' +import ReturnModal from '../components/ReturnModal' import ConfirmModal from '../components/ConfirmModal' export default function CommitteePage() { @@ -12,6 +13,7 @@ export default function CommitteePage() { const qc = useQueryClient() const [search, setSearch] = useState('') const [selectedItem, setSelectedItem] = useState(null) + const [returnItem, setReturnItem] = useState(null) const [modalMode, setModalMode] = useState('dispatch') // 'dispatch' | 'return' const [showDeleteCommittee, setShowDeleteCommittee] = useState(false) @@ -88,6 +90,23 @@ export default function CommitteePage() { } }) + const returnMutation = useMutation({ + mutationFn: async ({ itemId, quantity }) => { + const { error } = await supabase.rpc('return_item', { + p_committee_id: committeeId, + p_item_id: itemId, + p_quantity: quantity + }) + if (error) throw error + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['committee_inventory', committeeId] }) + qc.invalidateQueries({ queryKey: ['main_inventory'] }) + qc.invalidateQueries({ queryKey: ['dispatch_log'] }) + setReturnItem(null) + } + }) + const deleteCommittee = useMutation({ mutationFn: async () => { const { error } = await supabase.from('committees').delete().eq('id', committeeId) @@ -121,6 +140,7 @@ export default function CommitteePage() {

{committee?.name}

+ Total items dispatched to this committee. Tap a card to dispatch more, or return items to inventory. Items dispatched to this committee. Dispatch more or return items.

@@ -164,6 +184,8 @@ export default function CommitteePage() { showDispatch showReturn showLowStock={false} + onDispatch={() => setSelectedItem(item)} + onReturn={() => setReturnItem(item)} onDispatch={() => { setSelectedItem(item); setModalMode('dispatch') }} onReturn={() => { setSelectedItem(item); setModalMode('return') }} /> @@ -181,6 +203,18 @@ export default function CommitteePage() { error={activeMutation.error?.message} /> )} + + {returnItem && ( + returnMutation.mutate({ itemId: returnItem.id, quantity: qty })} + onClose={() => setReturnItem(null)} + isLoading={returnMutation.isPending} + error={returnMutation.error?.message} + /> + )}
) } \ No newline at end of file diff --git a/mun-logi-inventory/src/pages/DispatchLog.jsx b/mun-logi-inventory/src/pages/DispatchLog.jsx index 9acb960..ee01d0c 100644 --- a/mun-logi-inventory/src/pages/DispatchLog.jsx +++ b/mun-logi-inventory/src/pages/DispatchLog.jsx @@ -32,17 +32,6 @@ export default function DispatchLog() { } }) - const deleteLog = useMutation({ - mutationFn: async (logId) => { - const { error } = await supabase.from('dispatch_log').delete().eq('id', logId) - if (error) throw error - }, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['dispatch_log'] }) - setDeleteTarget(null) - } - }) - function formatTime(ts) { return new Date(ts).toLocaleString('en-IN', { day: '2-digit', month: 'short', @@ -106,6 +95,11 @@ export default function DispatchLog() { + + + + + @@ -117,6 +111,37 @@ export default function DispatchLog() { {logs?.map(log => { + const isReturn = log.quantity < 0 + return ( + + + + + + const isReturn = log.action_type === 'return' const isRequest = log.action_type === 'request' const pillStyle = isReturn
CommitteeItemQtyStatusTime Action Committee Item
{log.committee_name ?? '—'}{log.item_name ?? '—'} + + {isReturn ? `+${Math.abs(log.quantity)}` : log.quantity} + + + {isReturn ? ( + + + + + Returned + + ) : ( + + + + + Dispatched + + )} + {formatTime(log.dispatched_at)}