All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m34s
- Implemented ChoreAssignView for assigning chores to children. - Created ChoreConfirmDialog for confirming chore completion. - Developed KindnessAssignView for assigning kindness acts. - Added PenaltyAssignView for assigning penalties. - Introduced ChoreEditView and ChoreView for editing and viewing chores. - Created KindnessEditView and KindnessView for managing kindness acts. - Developed PenaltyEditView and PenaltyView for managing penalties. - Added TaskSubNav for navigation between chores, kindness acts, and penalties.
290 lines
7.4 KiB
Vue
290 lines
7.4 KiB
Vue
<template>
|
|
<h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2>
|
|
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
|
|
<form v-else @submit.prevent="submit" class="entity-form" ref="formRef">
|
|
<template v-for="field in fields" :key="field.name">
|
|
<div class="group">
|
|
<label :for="field.name">
|
|
{{ field.label }}
|
|
<!-- Custom field slot -->
|
|
<slot
|
|
:name="`custom-field-${field.name}`"
|
|
:modelValue="formData[field.name]"
|
|
:update="(val: unknown) => (formData[field.name] = val)"
|
|
>
|
|
<!-- Default rendering if no slot provided -->
|
|
<input
|
|
v-if="field.type === 'text'"
|
|
:id="field.name"
|
|
v-model="formData[field.name]"
|
|
type="text"
|
|
:required="field.required"
|
|
:maxlength="field.maxlength"
|
|
/>
|
|
<input
|
|
v-else-if="field.type === 'number'"
|
|
:id="field.name"
|
|
v-model="formData[field.name]"
|
|
type="number"
|
|
:required="field.required"
|
|
:min="field.min"
|
|
:max="field.max"
|
|
inputmode="numeric"
|
|
pattern="\\d{1,3}"
|
|
@input="
|
|
(e) => {
|
|
if (field.maxlength && e.target.value.length > field.maxlength) {
|
|
e.target.value = e.target.value.slice(0, field.maxlength)
|
|
formData[field.name] = e.target.value
|
|
}
|
|
}
|
|
"
|
|
/>
|
|
<ImagePicker
|
|
v-else-if="field.type === 'image'"
|
|
:id="field.name"
|
|
v-model="formData[field.name]"
|
|
:image-type="field.imageType || 1"
|
|
@add-image="onAddImage"
|
|
/>
|
|
</slot>
|
|
</label>
|
|
</div>
|
|
</template>
|
|
<div v-if="error" class="error">{{ error }}</div>
|
|
<div class="actions">
|
|
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="btn btn-primary"
|
|
:disabled="loading || !isValid || (props.requireDirty && !isDirty)"
|
|
>
|
|
{{ isEdit ? 'Save' : 'Create' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
|
import { useRouter } from 'vue-router'
|
|
import '@/assets/styles.css'
|
|
|
|
type Field = {
|
|
name: string
|
|
label: string
|
|
type: 'text' | 'number' | 'image' | 'custom'
|
|
required?: boolean
|
|
maxlength?: number
|
|
min?: number
|
|
max?: number
|
|
imageType?: number
|
|
}
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
entityLabel: string
|
|
fields: Field[]
|
|
initialData?: Record<string, any>
|
|
isEdit?: boolean
|
|
loading?: boolean
|
|
error?: string | null
|
|
title?: string
|
|
requireDirty?: boolean
|
|
}>(),
|
|
{
|
|
requireDirty: true,
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
|
|
|
const router = useRouter()
|
|
const formData = ref<Record<string, any>>({ ...props.initialData })
|
|
const baselineData = ref<Record<string, any>>({ ...props.initialData })
|
|
const formRef = ref<HTMLFormElement | null>(null)
|
|
|
|
async function focusFirstInput() {
|
|
await nextTick()
|
|
const firstInput = formRef.value?.querySelector<HTMLElement>('input, select, textarea')
|
|
firstInput?.focus()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await nextTick()
|
|
isDirty.value = false
|
|
if (!props.loading) {
|
|
focusFirstInput()
|
|
}
|
|
})
|
|
|
|
watch(
|
|
() => props.loading,
|
|
(newVal, oldVal) => {
|
|
if (!newVal && oldVal === true) {
|
|
focusFirstInput()
|
|
}
|
|
},
|
|
)
|
|
|
|
function onAddImage({ id, file }: { id: string; file: File }) {
|
|
emit('add-image', { id, file })
|
|
}
|
|
|
|
function onCancel() {
|
|
emit('cancel')
|
|
}
|
|
|
|
function submit() {
|
|
emit('submit', { ...formData.value })
|
|
// After submit, reset isDirty so Save button is disabled until next change
|
|
isDirty.value = false
|
|
}
|
|
|
|
// Editable field names (exclude custom fields that are not editable)
|
|
const editableFieldNames = props.fields
|
|
.filter((f) => f.type !== 'custom' || f.name === 'type')
|
|
.map((f) => f.name)
|
|
|
|
const isDirty = ref(false)
|
|
|
|
function getFieldByName(name: string): Field | undefined {
|
|
return props.fields.find((field) => field.name === name)
|
|
}
|
|
|
|
function valuesEqualForDirtyCheck(
|
|
fieldName: string,
|
|
currentValue: unknown,
|
|
initialValue: unknown,
|
|
): boolean {
|
|
const field = getFieldByName(fieldName)
|
|
|
|
if (field?.type === 'number') {
|
|
const currentEmpty = currentValue === '' || currentValue === null || currentValue === undefined
|
|
const initialEmpty = initialValue === '' || initialValue === null || initialValue === undefined
|
|
if (currentEmpty && initialEmpty) return true
|
|
if (currentEmpty !== initialEmpty) return false
|
|
return Number(currentValue) === Number(initialValue)
|
|
}
|
|
|
|
return JSON.stringify(currentValue) === JSON.stringify(initialValue)
|
|
}
|
|
|
|
function checkDirty() {
|
|
isDirty.value = editableFieldNames.some((key) => {
|
|
return !valuesEqualForDirtyCheck(key, formData.value[key], baselineData.value[key])
|
|
})
|
|
}
|
|
|
|
// Validation logic
|
|
const isValid = computed(() => {
|
|
return props.fields.every((field) => {
|
|
if (!field.required) return true
|
|
const value = formData.value[field.name]
|
|
|
|
if (field.type === 'text') {
|
|
return typeof value === 'string' && value.trim().length > 0
|
|
}
|
|
|
|
if (field.type === 'number') {
|
|
if (value === '' || value === null || value === undefined) return false
|
|
const numValue = Number(value)
|
|
if (isNaN(numValue)) return false
|
|
if (field.min !== undefined && numValue < field.min) return false
|
|
if (field.max !== undefined && numValue > field.max) return false
|
|
return true
|
|
}
|
|
|
|
// For other types, just check it's not null/undefined
|
|
return value != null
|
|
})
|
|
})
|
|
|
|
watch(
|
|
() => ({ ...formData.value }),
|
|
() => {
|
|
checkDirty()
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
watch(
|
|
() => props.initialData,
|
|
(newVal) => {
|
|
if (newVal) {
|
|
formData.value = { ...newVal }
|
|
baselineData.value = { ...newVal }
|
|
isDirty.value = false
|
|
}
|
|
},
|
|
{ immediate: true, deep: true },
|
|
)
|
|
</script>
|
|
|
|
<style scoped>
|
|
h2 {
|
|
text-align: center;
|
|
margin-bottom: 1.5rem;
|
|
color: var(--form-heading);
|
|
}
|
|
.entity-edit-view {
|
|
max-width: 420px;
|
|
margin: 0 auto;
|
|
background: var(--edit-view-bg, #fff);
|
|
border-radius: 14px;
|
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
|
padding: 2.2rem 2.2rem 1.5rem 2.2rem;
|
|
}
|
|
.entity-edit-view h2 {
|
|
text-align: center;
|
|
margin-bottom: 1.5rem;
|
|
color: var(--form-heading);
|
|
}
|
|
.entity-form .group {
|
|
margin-bottom: 1.2rem;
|
|
}
|
|
.entity-form label {
|
|
display: block;
|
|
font-weight: 600;
|
|
color: var(--form-label, #444);
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.entity-form input[type='text'],
|
|
.entity-form input[type='number'] {
|
|
width: 100%;
|
|
padding: 0.6rem;
|
|
border-radius: 7px;
|
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
|
font-size: 1rem;
|
|
background: var(--form-input-bg, #fff);
|
|
box-sizing: border-box;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: 3rem;
|
|
justify-content: center;
|
|
margin-top: 0.5rem;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.actions .btn {
|
|
padding: 1rem 2.2rem;
|
|
font-weight: 700;
|
|
font-size: 1.25rem;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.error {
|
|
color: var(--error-color, #e53e3e);
|
|
margin-bottom: 1rem;
|
|
font-size: 1rem;
|
|
}
|
|
.loading-message {
|
|
color: var(--loading-color, #888);
|
|
margin-bottom: 1.2rem;
|
|
font-size: 1rem;
|
|
}
|
|
</style>
|