Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions mun-logi-inventory/SQLCode
Original file line number Diff line number Diff line change
@@ -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;
$$;
19 changes: 19 additions & 0 deletions mun-logi-inventory/src/components/ItemCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@ export default function ItemCard({ item, quantity, onDispatch, onReturn, onUpdat
)}
</div>

{/* Action buttons */}
<div className="flex gap-2">
{showDispatch && (
<button
onClick={onDispatch}
className="flex-1 bg-lapis hover:bg-celestial active:bg-yale text-white py-2.5 rounded-lg text-xs font-semibold font-montserrat transition-colors"
>
Dispatch
</button>
)}
{showReturn && quantity > 0 && (
<button
onClick={onReturn}
className="flex-1 bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700 text-white py-2.5 rounded-lg text-xs font-semibold font-montserrat transition-colors"
>
↩ Return
</button>
)}
</div>
{/* Dispatch & Return buttons */}
{(showDispatch || showReturn) && (
<div className="flex gap-2">
Expand Down
73 changes: 73 additions & 0 deletions mun-logi-inventory/src/components/ReturnModal.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-white p-8 rounded-2xl w-full max-w-sm shadow-xl mx-auto"
onClick={e => e.stopPropagation()}
>
<h2 className="text-xl font-bold font-montserrat text-yale mb-1">Return Items</h2>
<p className="text-gray-400 text-sm mb-6 font-raleway">
Returning <span className="text-gray-800 font-semibold">{itemName}</span> from{' '}
<span className="text-lapis font-semibold">{committeeName}</span> back to inventory
</p>

{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg mb-4 text-sm border border-red-200 font-raleway">
{error}
</div>
)}

{/* Quantity selector */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => setQuantity(q => Math.max(1, q - 1))}
className="w-11 h-11 bg-gray-100 hover:bg-gray-200 text-gray-600 text-lg rounded-lg font-bold transition-colors flex items-center justify-center"
>
</button>
<input
type="number"
min={1}
max={maxQuantity}
value={quantity}
onChange={e => 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"
/>
<button
onClick={() => setQuantity(q => Math.min(maxQuantity, q + 1))}
className="w-11 h-11 bg-gray-100 hover:bg-gray-200 text-gray-600 text-lg rounded-lg font-bold transition-colors flex items-center justify-center"
>
+
</button>
</div>

<p className="text-xs text-gray-400 text-center mb-4 font-raleway">
Max returnable: {maxQuantity}
</p>

<div className="flex gap-3">
<button
onClick={() => onConfirm(quantity)}
disabled={isLoading}
className="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white py-3 rounded-lg font-semibold font-montserrat text-sm disabled:opacity-50 transition-colors"
>
{isLoading ? 'Returning...' : `Return ${quantity}`}
</button>
<button
onClick={onClose}
className="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-600 py-3 rounded-lg font-montserrat text-sm transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
)
}
34 changes: 34 additions & 0 deletions mun-logi-inventory/src/pages/CommitteePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -121,6 +140,7 @@ export default function CommitteePage() {
<div>
<h1 className="text-2xl font-bold font-montserrat text-yale">{committee?.name}</h1>
<p className="text-gray-400 text-sm mt-1 font-raleway">
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.
</p>
</div>
Expand Down Expand Up @@ -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') }}
/>
Expand All @@ -181,6 +203,18 @@ export default function CommitteePage() {
error={activeMutation.error?.message}
/>
)}

{returnItem && (
<ReturnModal
itemName={returnItem.name}
committeeName={committee?.name}
maxQuantity={returnItem.dispatched}
onConfirm={(qty) => returnMutation.mutate({ itemId: returnItem.id, quantity: qty })}
onClose={() => setReturnItem(null)}
isLoading={returnMutation.isPending}
error={returnMutation.error?.message}
/>
)}
</div>
)
}
Loading