Skip to content

Commit

Permalink
Bulk Unit Export to Excel (#115)
Browse files Browse the repository at this point in the history
## Tracking Info

Resolves #68 

## Changes

<!-- What changes did you make? -->

- implement bulk unit export
- various improvements

## Testing

<!-- How did you confirm your changes worked? -->

- ensure exports only filtered units and associated data

## Confirmation of Change

<!-- Upload a screenshot, if possible. Otherwise, please provide
instructions on how to see the change. -->


![image](https://github.com/TritonSE/USHS-Housing-Portal/assets/24444266/3420f23d-7553-4780-86d9-7b93c94dd023)

---------

Co-authored-by: Pranav Kumar Soma <[email protected]>
  • Loading branch information
petabite and soma-p authored Jun 14, 2024
1 parent e3228fe commit 50f4e76
Show file tree
Hide file tree
Showing 12 changed files with 276 additions and 139 deletions.
Binary file modified backend/bun.lockb
Binary file not shown.
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"module-alias": "^2.2.3",
"mongodb": "^5.7.0",
"mongoose": "^7.4.0",
"nodemailer": "^6.9.13"
"nodemailer": "^6.9.13",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
},
"name": "backend",
"version": "1.0.0",
Expand Down
10 changes: 10 additions & 0 deletions backend/src/controllers/units.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
approveUnit,
createUnit,
deleteUnit,
exportUnits,
getUnits,
updateUnit,
} from "@/services/units";
Expand Down Expand Up @@ -49,6 +50,15 @@ export const getUnitsHandler: RequestHandler = asyncHandler(async (req, res, _)
res.status(200).json(units);
});

export const exportUnitsHandler: RequestHandler = asyncHandler(async (req, res, _) => {
const workbookBuffer = await exportUnits(req.query as FilterParams);

res.statusCode = 200;
res.setHeader("Content-Disposition", 'attachment; filename="ushs-data-export.xlsx"');
res.setHeader("Content-Type", "application/vnd.ms-excel");
res.end(workbookBuffer);
});

/**
* Handle a request to get a unit.
*/
Expand Down
2 changes: 1 addition & 1 deletion backend/src/models/referral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const referralSchema = new Schema(
enum: ["Referred", "Viewing", "Pending", "Approved", "Denied", "Leased", "Canceled"],
default: "Referred",
},
renterCandidate: { type: Schema.Types.ObjectId, ref: "Renter" },
renterCandidate: { type: Schema.Types.ObjectId, ref: "Renter", required: true },
unit: {
type: Schema.Types.ObjectId,
ref: "Unit",
Expand Down
6 changes: 4 additions & 2 deletions backend/src/routes/units.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import { createUnitValidators, updateUnitValidators } from "@/validators/units";

const router = express.Router();

router.get("/", requireUser, UnitController.getUnitsHandler);

router.get("/export", requireUser, UnitController.exportUnitsHandler);

router.get("/:id", requireUser, UnitController.getUnitHandler);

router.post(
Expand All @@ -22,8 +26,6 @@ router.post(
UnitController.createUnitsHandler,
);

router.get("/", requireUser, UnitController.getUnitsHandler);

router.put(
"/:id",
requireHousingLocator,
Expand Down
66 changes: 65 additions & 1 deletion backend/src/services/units.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { FilterQuery, UpdateQuery } from "mongoose";
import { ObjectId } from "mongodb";
import { Document, FilterQuery, UpdateQuery } from "mongoose";
import * as XLSX from "xlsx";

import { ReferralModel } from "@/models/referral";
import { RenterModel } from "@/models/renter";
import { Unit, UnitModel } from "@/models/units";
import { UserModel } from "@/models/user";

type UserReadOnlyFields = "approved" | "createdAt" | "updatedAt";

Expand Down Expand Up @@ -223,3 +228,62 @@ export const getUnits = async (filters: FilterParams) => {

return filteredUnits;
};

const sheetFromData = (data: Document[]) => {
const sanitizedData = data.map((doc) => {
// remove unneeded keys and convert all values to strings
const { _id, __v, ...rest } = doc.toJSON() as Record<string, string>;
const sanitizedRest = Object.keys(rest).reduce<Record<string, string>>((acc, key) => {
const value = rest[key];
if ((value as unknown) instanceof ObjectId) {
acc[key] = value.toString();
} else if (Array.isArray(value)) {
acc[key] = JSON.stringify(value);
} else {
acc[key] = value;
}
return acc;
}, {});

return {
id: _id.toString(),
...sanitizedRest,
};
});
return XLSX.utils.json_to_sheet(sanitizedData);
};

export const exportUnits = async (filters: FilterParams) => {
const unitsData = await getUnits(filters);

const unitIds = unitsData.map((unit) => unit._id);
const referralsData = await ReferralModel.find().where("unit").in(unitIds).exec();

const renterCandidateIds = [
...new Set(referralsData.map((referral) => referral.renterCandidate)),
];
const renterCandidates = await RenterModel.find().where("_id").in(renterCandidateIds).exec();

const housingLocatorIds = [
...new Set(referralsData.map((referral) => referral.assignedHousingLocator)),
];
const referringStaffIds = [
...new Set(referralsData.map((referral) => referral.assignedReferringStaff)),
];
const staffIds = housingLocatorIds.concat(referringStaffIds);
const staffData = await UserModel.find().where("_id").in(staffIds).exec();

// Generate Excel workbook
const unitsSheet = sheetFromData(unitsData);
const referralsSheet = sheetFromData(referralsData);
const renterCandidatesSheet = sheetFromData(renterCandidates);
const staffSheet = sheetFromData(staffData);

const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, unitsSheet, "Units");
XLSX.utils.book_append_sheet(workbook, referralsSheet, "Referrals");
XLSX.utils.book_append_sheet(workbook, renterCandidatesSheet, "Renter Candidates");
XLSX.utils.book_append_sheet(workbook, staffSheet, "Staff");

return XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
};
3 changes: 3 additions & 0 deletions frontend/public/export-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions frontend/src/api/units.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ export async function getUnits(params: GetUnitsParams): Promise<APIResult<Unit[]
}
}

export async function exportUnits(params: GetUnitsParams): Promise<APIResult<Blob>> {
try {
const queryParams = new URLSearchParams(params);
const url = `/units/export?${queryParams.toString()}`;
const response = await get(url);

const data = await response.blob();
return { success: true, data };
} catch (error) {
return handleAPIError(error);
}
}

type HousingLocatorFields =
| "leasedStatus"
| "whereFound"
Expand Down
88 changes: 88 additions & 0 deletions frontend/src/components/ExportPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import styled from "styled-components";

import { Button } from "./Button";

const Overlay = styled.div`
width: 100vw;
height: 100vh;
top: 0;
left: 0;
right: 0;
bottom: 0;
position: fixed;
background: rgba(0, 0, 0, 0.25);
z-index: 2;
`;

const Modal = styled.div`
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 20px;
background: #fff;
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 30px;
z-index: 2;
padding: 97px 186px;
`;

const HeadingWrapper = styled.div`
font-family: "Neutraface Text";
font-size: 32px;
font-style: normal;
font-weight: 700;
text-align: center;
`;

const MessageWrapper = styled.div`
font-size: 16px;
margin-top: 10px;
text-align: center;
`;

const ButtonsWrapper = styled.div`
padding-top: 25px;
display: flex;
flex-direction: row;
gap: 400px;
`;

const Icon = styled.img`
width: 78px;
height: 78px;
`;

type PopupProps = {
active: boolean;
onClose: () => void;
};

export const ExportPopup = ({ active, onClose }: PopupProps) => {
if (!active) return null;

return (
<>
<Overlay />
<Modal>
<Icon src="/dark_green_check.svg" />
<div>
<HeadingWrapper>Data Exporting...</HeadingWrapper>
<MessageWrapper>
Generating an Excel sheet with the currently filtered Units and associated Referrals and
Renter Candidates. The download will start shortly...
</MessageWrapper>
</div>
<ButtonsWrapper>
<Button onClick={onClose} kind="primary">
Done
</Button>
</ButtonsWrapper>
</Modal>
</>
);
};
57 changes: 30 additions & 27 deletions frontend/src/components/UnitCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,19 @@ import { FiltersContext } from "@/pages/Home";
const UnitCardContainer = styled.div<{ pending: boolean }>`
display: flex;
flex-direction: column;
justify-content: flex-start;
justify-content: space-between;
align-content: flex-start;
gap: 8px;
padding-left: 20px;
padding-right: 20px;
padding-top: 20px;
width: 318px;
padding: 20px;
height: 370px;
width: 330px;
background-color: white;
border-radius: 6.5px;
border: 1.3px solid ${(props) => (props.pending ? "rgba(230, 159, 28, 0.50)" : "#cdcaca")};
box-shadow: 1.181px 1.181px 2.362px 0px rgba(188, 186, 183, 0.4);
// position: absolute;
&:hover {
box-shadow: 0px 10px 20px 0px rgba(0, 0, 0, 0.15);
}
`;

const UnitCardText = styled.span`
Expand Down Expand Up @@ -56,13 +54,18 @@ const BedBathRow = styled.div`
gap: 4px;
`;

const AddressRow = styled.div`
const Address = styled.div`
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
`;

const BottomRow = styled.div`
display: flex;
justify-content: space-between;
`;

const AvailabilityIcon = styled.img`
width: 18px;
height: 18px;
Expand Down Expand Up @@ -112,10 +115,8 @@ const BedBathText = styled(NumberText)`
const DeleteIcon = styled.img`
width: 22px;
height: 24px;
position: relative;
top: -32px;
left: 250px;
cursor: pointer;
align-self: flex-end;
`;

const Overlay = styled.div`
Expand Down Expand Up @@ -311,21 +312,23 @@ export const UnitCard = ({ unit, refreshUnits }: CardProps) => {
<NumberText>{unit.sqft}</NumberText>
<BedBathText>sqft</BedBathText>
</BedBathRow>
<AddressRow>
<AddressText>{unit.streetAddress}</AddressText>
<AddressText>{`${unit.city}, ${unit.state} ${unit.areaCode}`}</AddressText>
</AddressRow>
{unit.approved && dataContext.currentUser?.isHousingLocator && (
<DeleteIcon
src="Trash_Icon.svg"
onClick={(e) => {
// Stop click from propagating to parent (opening the unit page)
e.preventDefault();
e.stopPropagation();
setPopup(true);
}}
/>
)}
<BottomRow>
<Address>
<AddressText>{unit.streetAddress}</AddressText>
<AddressText>{`${unit.city}, ${unit.state} ${unit.areaCode}`}</AddressText>
</Address>
{unit.approved && dataContext.currentUser?.isHousingLocator && (
<DeleteIcon
src="Trash_Icon.svg"
onClick={(e) => {
// Stop click from propagating to parent (opening the unit page)
e.preventDefault();
e.stopPropagation();
setPopup(true);
}}
/>
)}
</BottomRow>
</UnitCardContainer>
</Link>
{popup && (
Expand Down
Loading

0 comments on commit 50f4e76

Please sign in to comment.