Merge branch 'main' into #134-Calendar-Upgrades
This commit is contained in:
@@ -16,10 +16,11 @@ ur.use(requireLogin)
|
|||||||
ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), requireMemberState(MemberState.Member)], async (req: express.Request, res: express.Response) => {
|
ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), requireMemberState(MemberState.Member)], async (req: express.Request, res: express.Response) => {
|
||||||
try {
|
try {
|
||||||
const change = req.body.promotions as BatchPromotionMember[];
|
const change = req.body.promotions as BatchPromotionMember[];
|
||||||
|
const approver = req.body.approver as number;
|
||||||
const author = req.user.id;
|
const author = req.user.id;
|
||||||
if (!change) res.sendStatus(400);
|
if (!change) res.sendStatus(400);
|
||||||
|
|
||||||
await batchInsertMemberRank(change, author);
|
await batchInsertMemberRank(change, author, approver);
|
||||||
logger.info('app', 'Promotion batch submitted', { author: author })
|
logger.info('app', 'Promotion batch submitted', { author: author })
|
||||||
res.sendStatus(201);
|
res.sendStatus(201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BatchPromotion, BatchPromotionMember } from "@app/shared/schemas/promot
|
|||||||
import { PromotionDetails, PromotionSummary } from "@app/shared/types/rank"
|
import { PromotionDetails, PromotionSummary } from "@app/shared/types/rank"
|
||||||
import pool from "../../db";
|
import pool from "../../db";
|
||||||
import { PagedData } from "@app/shared/types/pagination";
|
import { PagedData } from "@app/shared/types/pagination";
|
||||||
import { toDateTime } from "@app/shared/utils/time";
|
import { toDate, toDateIgnoreZone, toDateTime } from "@app/shared/utils/time";
|
||||||
|
|
||||||
export async function getAllRanks() {
|
export async function getAllRanks() {
|
||||||
const rows = await pool.query(
|
const rows = await pool.query(
|
||||||
@@ -36,11 +36,11 @@ export async function insertMemberRank(member_id: number, rank_id: number, date?
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function batchInsertMemberRank(promos: BatchPromotionMember[], author: number) {
|
export async function batchInsertMemberRank(promos: BatchPromotionMember[], author: number, approver: number) {
|
||||||
try {
|
try {
|
||||||
var con = await pool.getConnection();
|
var con = await pool.getConnection();
|
||||||
promos.forEach(p => {
|
promos.forEach(p => {
|
||||||
con.query(`CALL sp_update_member_rank(?, ?, ?, ?, ?, ?)`, [p.member_id, p.rank_id, author, author, "Rank Change", toDateTime(new Date(p.start_date))])
|
con.query(`CALL sp_update_member_rank(?, ?, ?, ?, ?, ?)`, [p.member_id, p.rank_id, approver, author, "Rank Change", toDateIgnoreZone(new Date(p.start_date))])
|
||||||
});
|
});
|
||||||
|
|
||||||
con.commit();
|
con.commit();
|
||||||
@@ -91,8 +91,10 @@ export async function getPromotionsOnDay(day: Date): Promise<PromotionDetails[]>
|
|||||||
// SQL query to fetch all records from members_unit for the specified day
|
// SQL query to fetch all records from members_unit for the specified day
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT
|
SELECT
|
||||||
mr.member_id,
|
mr.id AS promo_id,
|
||||||
|
mr.member_id,
|
||||||
mr.created_by_id,
|
mr.created_by_id,
|
||||||
|
mr.authorized_by_id,
|
||||||
r.short_name
|
r.short_name
|
||||||
FROM members_ranks AS mr
|
FROM members_ranks AS mr
|
||||||
LEFT JOIN ranks AS r ON r.id = mr.rank_id
|
LEFT JOIN ranks AS r ON r.id = mr.rank_id
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const batchPromotionMemberSchema = z.object({
|
|||||||
|
|
||||||
export const batchPromotionSchema = z.object({
|
export const batchPromotionSchema = z.object({
|
||||||
promotions: z.array(batchPromotionMemberSchema, { message: "At least one promotion is required" }).nonempty({ message: "At least one promotion is required" }),
|
promotions: z.array(batchPromotionMemberSchema, { message: "At least one promotion is required" }).nonempty({ message: "At least one promotion is required" }),
|
||||||
|
approver: z.number({ invalid_type_error: "Must select a member" }).int().positive()
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
// optional: check for duplicate member_ids
|
// optional: check for duplicate member_ids
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ export interface PromotionSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PromotionDetails {
|
export interface PromotionDetails {
|
||||||
|
promo_id: number;
|
||||||
member_id: number;
|
member_id: number;
|
||||||
short_name: string;
|
short_name: string;
|
||||||
created_by_id: number;
|
created_by_id: number;
|
||||||
|
authorized_by_id: number;
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,34 @@ export function toDateTime(date: Date): string {
|
|||||||
date = new Date(date);
|
date = new Date(date);
|
||||||
}
|
}
|
||||||
// This produces a CST-local time because server runs in CST
|
// This produces a CST-local time because server runs in CST
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||||
const day = date.getDate().toString().padStart(2, "0");
|
const day = date.getDate().toString().padStart(2, "0");
|
||||||
const hour = date.getHours().toString().padStart(2, "0");
|
const hour = date.getHours().toString().padStart(2, "0");
|
||||||
const minute = date.getMinutes().toString().padStart(2, "0");
|
const minute = date.getMinutes().toString().padStart(2, "0");
|
||||||
const second = date.getSeconds().toString().padStart(2, "0");
|
const second = date.getSeconds().toString().padStart(2, "0");
|
||||||
|
|
||||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toDateIgnoreZone(date: Date): string {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
date = new Date(date);
|
||||||
|
}
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDate(date: Date): string {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
date = new Date(date);
|
||||||
|
}
|
||||||
|
console.log(date);
|
||||||
|
// This produces a CST-local date because server runs in CST
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = date.getDate().toString().padStart(2, "0");
|
||||||
|
let out = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
console.log(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ import { error } from 'console';
|
|||||||
import Input from '../ui/input/Input.vue';
|
import Input from '../ui/input/Input.vue';
|
||||||
import Field from '../ui/field/Field.vue';
|
import Field from '../ui/field/Field.vue';
|
||||||
|
|
||||||
const { handleSubmit, errors, values, resetForm, setFieldValue } = useForm({
|
const { handleSubmit, errors, values, resetForm, setFieldValue, submitCount } = useForm({
|
||||||
validationSchema: toTypedSchema(batchPromotionSchema),
|
validationSchema: toTypedSchema(batchPromotionSchema),
|
||||||
validateOnMount: false,
|
validateOnMount: false,
|
||||||
})
|
})
|
||||||
@@ -38,6 +38,7 @@ const submitForm = handleSubmit(
|
|||||||
output.promotions.map(p => p.start_date = new Date(p.start_date).toISOString())
|
output.promotions.map(p => p.start_date = new Date(p.start_date).toISOString())
|
||||||
await submitRankChange(output);
|
await submitRankChange(output);
|
||||||
formSubmitted.value = true;
|
formSubmitted.value = true;
|
||||||
|
emit("submitted");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
submitError.value = error;
|
submitError.value = error;
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -45,6 +46,10 @@ const submitForm = handleSubmit(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submitted: [void]
|
||||||
|
}>();
|
||||||
|
|
||||||
const submitError = ref<string>(null);
|
const submitError = ref<string>(null);
|
||||||
const formSubmitted = ref(false);
|
const formSubmitted = ref(false);
|
||||||
|
|
||||||
@@ -123,21 +128,15 @@ function setAllToday() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-xl">
|
<div class="w-xl">
|
||||||
<form v-if="!formSubmitted" id="trainingForm" @submit.prevent="submitForm"
|
<form v-if="!formSubmitted" id="trainingForm" @submit.prevent="submitForm"
|
||||||
class="w-full min-w-0 flex flex-col gap-6">
|
class="w-full min-w-0 flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<FieldLegend class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||||
|
Promotion Form
|
||||||
|
</FieldLegend>
|
||||||
|
</div>
|
||||||
<VeeFieldArray name="promotions" v-slot="{ fields, push, remove }">
|
<VeeFieldArray name="promotions" v-slot="{ fields, push, remove }">
|
||||||
<FieldSet class="w-full min-w-0">
|
<FieldSet class="w-full min-w-0">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div>
|
|
||||||
<FieldLegend class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
|
||||||
Promotion Form
|
|
||||||
</FieldLegend>
|
|
||||||
<div class="h-6">
|
|
||||||
<p v-if="errors.promotions && typeof errors.promotions === 'string'"
|
|
||||||
class="text-sm text-red-500">
|
|
||||||
{{ errors.promotions }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- TABLE SHELL -->
|
<!-- TABLE SHELL -->
|
||||||
<div class="">
|
<div class="">
|
||||||
<FieldGroup class="">
|
<FieldGroup class="">
|
||||||
@@ -249,9 +248,48 @@ function setAllToday() {
|
|||||||
</div>
|
</div>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</VeeFieldArray>
|
</VeeFieldArray>
|
||||||
<div class="flex justify-end items-center gap-5">
|
<div class="flex justify-between items-start">
|
||||||
<p v-if="submitError" class="text-destructive">{{ submitError }}</p>
|
<VeeField name="approver" v-slot="{ field, errors }">
|
||||||
<Button type="submit">Submit</Button>
|
<div class="flex flex-col min-w-0 gap-2">
|
||||||
|
<p>Approved By</p>
|
||||||
|
<Combobox :model-value="field.value" @update:model-value="field.onChange" :ignore-filter="true">
|
||||||
|
<ComboboxAnchor>
|
||||||
|
<ComboboxInput class="w-full pl-3" placeholder="Search members…" :display-value="id =>
|
||||||
|
memberById.get(id)?.displayName ||
|
||||||
|
memberById.get(id)?.username
|
||||||
|
" @input="memberSearch = $event.target.value" />
|
||||||
|
</ComboboxAnchor>
|
||||||
|
<ComboboxList>
|
||||||
|
<ComboboxEmpty>No results</ComboboxEmpty>
|
||||||
|
<ComboboxGroup>
|
||||||
|
<div class="max-h-80 overflow-y-auto min-w-[12rem] scrollbar-themed">
|
||||||
|
<ComboboxItem v-for="member in filteredMembers" :key="member.id"
|
||||||
|
:value="member.id">
|
||||||
|
{{ member.displayName || member.username }}
|
||||||
|
<ComboboxItemIndicator>
|
||||||
|
<Check />
|
||||||
|
</ComboboxItemIndicator>
|
||||||
|
</ComboboxItem>
|
||||||
|
</div>
|
||||||
|
</ComboboxGroup>
|
||||||
|
</ComboboxList>
|
||||||
|
</Combobox>
|
||||||
|
<div class="h-5">
|
||||||
|
<FieldError v-if="errors.length" :errors="errors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VeeField>
|
||||||
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<div class="h-6" />
|
||||||
|
<Button type="submit" class="w-min">Submit</Button>
|
||||||
|
<p v-if="submitError" class="text-destructive">{{ submitError }}</p>
|
||||||
|
<div v-else class="h-6 flex justify-end">
|
||||||
|
<p v-if="submitCount > 0 && errors.promotions && typeof errors.promotions === 'string'"
|
||||||
|
class="text-sm text-red-500">
|
||||||
|
{{ errors.promotions }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ async function loadHistory() {
|
|||||||
pageData.value = d.pagination;
|
pageData.value = d.pagination;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
loadHistory();
|
||||||
|
promoDayDetails.value?.[0].loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
refresh
|
||||||
|
})
|
||||||
|
|
||||||
|
const promoDayDetails = ref<InstanceType<typeof PromotionListDay>[]>(null)
|
||||||
|
|
||||||
const expanded = ref<number | null>(null);
|
const expanded = ref<number | null>(null);
|
||||||
const hoverID = ref<number | null>(null);
|
const hoverID = ref<number | null>(null);
|
||||||
|
|
||||||
@@ -72,9 +83,6 @@ function formatDate(date: Date): string {
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col max-w-7xl w-full">
|
<div class="flex flex-col max-w-7xl w-full">
|
||||||
<p class="scroll-m-20 text-2xl font-semibold tracking-tight mb-3">
|
|
||||||
Promotion History
|
|
||||||
</p>
|
|
||||||
<div class="w-full mx-auto">
|
<div class="w-full mx-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -107,7 +115,8 @@ function formatDate(date: Date): string {
|
|||||||
:class="{ 'bg-muted/50 border-t-0': hoverID === index }">
|
:class="{ 'bg-muted/50 border-t-0': hoverID === index }">
|
||||||
<TableCell :colspan="8" class="p-0">
|
<TableCell :colspan="8" class="p-0">
|
||||||
<div class="w-full p-2 mb-6 space-y-3">
|
<div class="w-full p-2 mb-6 space-y-3">
|
||||||
<PromotionListDay :date="new Date(batch.entry_day)"></PromotionListDay>
|
<PromotionListDay ref="promoDayDetails" :date="new Date(batch.entry_day)">
|
||||||
|
</PromotionListDay>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -118,7 +127,7 @@ function formatDate(date: Date): string {
|
|||||||
<div v-if="loading" class="w-full flex mx-auto justify-center my-15">
|
<div v-if="loading" class="w-full flex mx-auto justify-center my-15">
|
||||||
<Spinner class="size-7"></Spinner>
|
<Spinner class="size-7"></Spinner>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-between">
|
<div class="mt-5 flex justify-between mb-20">
|
||||||
<div></div>
|
<div></div>
|
||||||
<Pagination v-slot="{ page }" :items-per-page="pageData?.pageSize || 10" :total="pageData?.total || 10"
|
<Pagination v-slot="{ page }" :items-per-page="pageData?.pageSize || 10" :total="pageData?.total || 10"
|
||||||
:default-page="2" :page="pageNum" @update:page="setPage">
|
:default-page="2" :page="pageNum" @update:page="setPage">
|
||||||
|
|||||||
@@ -13,8 +13,17 @@ const props = defineProps<{
|
|||||||
const promoList = ref<PromotionDetails[]>();
|
const promoList = ref<PromotionDetails[]>();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadData() {
|
||||||
promoList.value = await getPromotionsOnDay(props.date);
|
promoList.value = await getPromotionsOnDay(props.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
loadData
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// promoList.value = await getPromotionsOnDay(props.date);
|
||||||
|
await loadData();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -27,7 +36,8 @@ onMounted(async () => {
|
|||||||
<tr class="border-b-2 border-gray-200 bg-white/10">
|
<tr class="border-b-2 border-gray-200 bg-white/10">
|
||||||
<th class="px-4 py-3 text-sm font-semibold">Member</th>
|
<th class="px-4 py-3 text-sm font-semibold">Member</th>
|
||||||
<th class="px-4 py-3 text-sm font-semibold">Rank</th>
|
<th class="px-4 py-3 text-sm font-semibold">Rank</th>
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-right">Approved By</th>
|
<th class="px-4 py-3 text-sm font-semibold">Approved By</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-right">Submitted By</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
@@ -39,6 +49,9 @@ onMounted(async () => {
|
|||||||
<td class="px-4 py-2 text-sm">
|
<td class="px-4 py-2 text-sm">
|
||||||
{{ p.short_name }}
|
{{ p.short_name }}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-2 py-2 text-sm text-right">
|
||||||
|
<MemberCard :member-id="p.authorized_by_id" />
|
||||||
|
</td>
|
||||||
<td class="px-2 py-2 text-sm text-right">
|
<td class="px-2 py-2 text-sm text-right">
|
||||||
<MemberCard :member-id="p.created_by_id" />
|
<MemberCard :member-id="p.created_by_id" />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,24 +1,65 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import PromotionForm from "@/components/promotions/promotionForm.vue";
|
|
||||||
import PromotionList from "@/components/promotions/promotionList.vue";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto mt-10 max-w-7xl px-8">
|
<div class="mx-auto mt-6 lg:mt-10 px-4 lg:px-8">
|
||||||
<div class="flex max-h-[70vh] gap-8">
|
|
||||||
|
|
||||||
<!-- LEFT COLUMN -->
|
<div class="flex items-center justify-between mb-6 lg:hidden">
|
||||||
<div class="flex-1 border-r pr-8">
|
<h1 class="text-2xl font-semibold tracking-tight">Promotion History</h1>
|
||||||
<PromotionList></PromotionList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RIGHT COLUMN -->
|
<Dialog v-model:open="isFormOpen">
|
||||||
<div class="flex-1 flex justify-center pl-8">
|
<DialogTrigger as-child>
|
||||||
<div class="w-full max-w-3xl">
|
<Button size="sm" class="gap-2">
|
||||||
<PromotionForm />
|
<Plus class="size-4" />
|
||||||
|
Promote
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent class="w-full h-full max-w-none m-0 rounded-none flex flex-col">
|
||||||
|
<DialogHeader class="flex-row items-center justify-between border-b pb-4">
|
||||||
|
<DialogTitle>New Promotion</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto pt-6">
|
||||||
|
<PromotionForm @submitted="handleMobileSubmit" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row lg:max-h-[70vh] gap-8">
|
||||||
|
<div class="flex-1 lg:border-r lg:pr-8 w-full lg:min-w-2xl">
|
||||||
|
<p class="hidden lg:block scroll-m-20 text-2xl font-semibold tracking-tight mb-3">
|
||||||
|
Promotion History
|
||||||
|
</p>
|
||||||
|
<div class="overflow-y-auto lg:max-h-full">
|
||||||
|
<PromotionList ref="listRef"></PromotionList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden lg:flex flex-1 pl-8">
|
||||||
|
<div class="w-full max-w-3xl">
|
||||||
|
<p class="text-2xl font-semibold tracking-tight mb-3">New Promotion</p>
|
||||||
|
<PromotionForm @submitted="listRef?.refresh" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import PromotionForm from '@/components/promotions/promotionForm.vue'
|
||||||
|
import PromotionList from '@/components/promotions/promotionList.vue'
|
||||||
|
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||||
|
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||||
|
import DialogTrigger from '@/components/ui/dialog/DialogTrigger.vue'
|
||||||
|
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||||
|
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
|
||||||
|
const isFormOpen = ref(false)
|
||||||
|
const listRef = ref(null)
|
||||||
|
|
||||||
|
const handleMobileSubmit = () => {
|
||||||
|
isFormOpen.value = false // Close the "Whole page" modal
|
||||||
|
listRef.value?.refresh() // Refresh the list behind it
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user