diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 8ef85cd87d535934e78950933dad314402950299..15d9ba8ec30ebf39275efc0563098b1363e5fe15 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -1,188 +1,98 @@
 <script setup>
-import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
-import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue";
-import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue";
 import ValidityRangeField from "../validity_range/ValidityRangeField.vue";
 import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
+import TimeboundCourseConfigRasterCell from "./TimeboundCourseConfigRasterCell.vue";
 </script>
 
 <template>
-  <div>
-    <v-data-table
-      disable-sort
-      disable-filtering
-      disable-pagination
-      hide-default-footer
-      :headers="headers"
-      :items="tableItems"
-    >
-      <template #top>
-        <v-row>
-          <v-col
-            cols="6"
-            lg="3"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <v-autocomplete
-              outlined
-              filled
-              multiple
-              hide-details
-              :items="groupsForPlanning"
-              item-text="shortName"
-              item-value="id"
-              return-object
-              :disabled="$apollo.queries.groupsForPlanning.loading"
-              :label="$t('lesrooster.timebound_course_config.groups')"
-              :loading="$apollo.queries.groupsForPlanning.loading"
-              v-model="selectedGroups"
-              class="mr-4"
-            />
-          </v-col>
+  <v-data-table
+    disable-sort
+    disable-filtering
+    disable-pagination
+    hide-default-footer
+    :headers="headers"
+    :items="items"
+    :loading="loading"
+  >
+    <template #top>
+      <v-row>
+        <v-col
+          cols="6"
+          lg="3"
+          class="d-flex justify-space-between flex-wrap align-center"
+        >
+          <v-autocomplete
+            outlined
+            filled
+            multiple
+            hide-details
+            :items="groupsForPlanning"
+            item-text="shortName"
+            item-value="id"
+            return-object
+            :disabled="$apollo.queries.groupsForPlanning.loading"
+            :label="$t('lesrooster.timebound_course_config.groups')"
+            :loading="$apollo.queries.groupsForPlanning.loading"
+            v-model="selectedGroups"
+            class="mr-4"
+          />
+        </v-col>
 
-          <v-col
-            cols="6"
-            lg="3"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <validity-range-field
-              outlined
-              filled
-              label="Select Validity Range"
-              hide-details
-              v-model="internalValidityRange"
-              :loading="$apollo.queries.currentValidityRange.loading"
-            />
-          </v-col>
+        <v-col
+          cols="6"
+          lg="3"
+          class="d-flex justify-space-between flex-wrap align-center"
+        >
+          <validity-range-field
+            outlined
+            filled
+            hide-details
+            v-model="internalValidityRange"
+            :loading="$apollo.queries.currentValidityRange.loading"
+          />
+        </v-col>
 
-          <v-spacer />
+        <v-col
+          cols="6"
+          lg="2"
+          class="d-flex justify-space-between flex-wrap align-center"
+        >
+          <v-switch
+            v-model="includeChildGroups"
+            inset
+            :label="
+              $t(
+                'lesrooster.timebound_course_config.filters.include_child_groups',
+              )
+            "
+            :loading="$apollo.queries.subjects.loading"
+          ></v-switch>
+        </v-col>
 
-          <v-col
-            cols="8"
-            lg="3"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <secondary-action-button
-              i18n-key="actions.copy_last_configuration"
-              block
-              class="mr-4"
-            />
-          </v-col>
-          <v-col
-            cols="4"
-            lg="1"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <save-button
-              :disabled="
-                !editedCourseConfigs.length &&
-                !createdCourseConfigs.length &&
-                !createdCourses.length
-              "
-              @click="save"
-            />
-          </v-col>
-        </v-row>
-      </template>
+        <v-spacer />
+      </v-row>
+    </template>
 
-      <!-- eslint-disable-next-line vue/valid-v-slot -->
-      <template #item.subject="{ item, value }">
-        <subject-chip v-if="value" :subject="value" />
-      </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #item.subject="{ item, value }">
+      <subject-chip v-if="value" :subject="value" />
+    </template>
 
-      <template
-        v-for="(groupHeader, index) in groupHeaders"
-        #[tableItemSlotName(groupHeader)]="{ item, value, header }"
-      >
-        <div :key="index">
-          <div v-if="value.length">
-            <v-row
-              v-for="(course, index) in value"
-              :key="index"
-              no-gutters
-              class="mt-2"
-            >
-              <v-col cols="6">
-                <positive-small-integer-field
-                  dense
-                  filled
-                  class="mx-1"
-                  :disabled="loading"
-                  :value="
-                    getCurrentCourseConfig(course)
-                      ? getCurrentCourseConfig(course).lessonQuota
-                      : course.lessonQuota
-                  "
-                  :label="$t('lesrooster.timebound_course_config.lesson_quota')"
-                  @input="
-                    (event) =>
-                      setCourseConfigData(course, item.subject, header, {
-                        lessonQuota: event,
-                      })
-                  "
-                />
-              </v-col>
-              <v-col cols="6">
-                <v-autocomplete
-                  counter
-                  dense
-                  filled
-                  multiple
-                  :items="getTeacherList(item.subject.teachers)"
-                  item-text="fullName"
-                  item-value="id"
-                  class="mx-1"
-                  :disabled="loading"
-                  :label="$t('lesrooster.timebound_course_config.teachers')"
-                  :value="
-                    getCurrentCourseConfig(course)
-                      ? getCurrentCourseConfig(course).teachers
-                      : course.teachers
-                  "
-                  @input="
-                    (event) =>
-                      setCourseConfigData(course, item.subject, header, {
-                        teachers: event,
-                      })
-                  "
-                >
-                  <template #item="data">
-                    <template v-if="typeof data.item !== 'object'">
-                      <v-list-item-content>{{ data.item }}</v-list-item-content>
-                    </template>
-                    <template v-else>
-                      <v-list-item-action>
-                        <v-checkbox v-model="data.attrs.inputValue" />
-                      </v-list-item-action>
-                      <v-list-item-content>
-                        <v-list-item-title>{{
-                          data.item.fullName
-                        }}</v-list-item-title>
-                        <v-list-item-subtitle v-if="data.item.shortName">{{
-                          data.item.shortName
-                        }}</v-list-item-subtitle>
-                      </v-list-item-content>
-                    </template>
-                  </template>
-                </v-autocomplete>
-              </v-col>
-            </v-row>
-          </div>
-          <div v-if="!value.length">
-            <v-btn
-              block
-              icon
-              tile
-              outlined
-              @click="addCourse(item.subject.id, header.value)"
-            >
-              <v-icon>mdi-plus</v-icon>
-            </v-btn>
-          </div>
-        </div>
-      </template>
-    </v-data-table>
-  </div>
+    <template
+      v-for="(groupHeader, index) in groupHeaders"
+      #[tableItemSlotName(groupHeader)]="{ item, value, header }"
+    >
+      <timebound-course-config-raster-cell
+        :key="index"
+        :value="value"
+        :subject="item.subject"
+        :header="header"
+        :loading="loading"
+        @addCourse="addCourse"
+        @setCourseConfigData="setCourseConfigData"
+      />
+    </template>
+  </v-data-table>
 </template>
 
 <script>
@@ -190,21 +100,18 @@ import {
   subjects,
   createTimeboundCourseConfigs,
   updateTimeboundCourseConfigs,
-  createCoursesForSchoolTerm,
+  createCoursesForValidityRange,
 } from "./timeboundCourseConfig.graphql";
 
 import { currentValidityRange as gqlCurrentValidityRange } from "../validity_range/validityRange.graphql";
 
-import {
-  gqlGroupsForPlanning,
-  gqlGroups,
-  gqlTeachers,
-} from "../helper.graphql";
+import { gqlGroupsForPlanning } from "../helper.graphql";
 
-import { createCourses } from "aleksis.apps.cursus/components/course.graphql";
+import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
 
 export default {
   name: "TimeboungCourseConfigRaster",
+  mixins: [mutateMixin],
   data() {
     return {
       i18nKey: "lesrooster.timebound_course_config",
@@ -230,40 +137,30 @@ export default {
       subjects: [],
       editedCourseConfigs: [],
       createdCourseConfigs: [],
-      newCourses: [],
       createdCourses: [],
       currentCourse: null,
       currentSubject: null,
-      loading: false,
+      includeChildGroups: true,
+      selectedGroupHeaders: [],
+      items: [],
+      groupCombinationsSet: new Set(),
     };
   },
   methods: {
     tableItemSlotName(header) {
       return "item." + header.value;
     },
-    getCurrentCourseConfig(course) {
-      if (course.lrTimeboundCourseConfigs?.length) {
-        let currentCourseConfigs = course.lrTimeboundCourseConfigs.filter(
-          (timeboundConfig) =>
-            timeboundConfig.validityRange.id === this.internalValidityRange.id,
-        );
-        if (currentCourseConfigs.length) {
-          return currentCourseConfigs[0];
-        } else {
-          return null;
-        }
-      } else {
-        return null;
-      }
-    },
     setCourseConfigData(course, subject, header, newValue) {
+      // Handles input from individual raster cells.
       if (course.newCourse) {
+        // No course for the given combination has been created yet.
         let existingCreatedCourse = this.createdCourses.find(
           (c) =>
             c.subject === subject.id &&
             JSON.stringify(c.groups) === header.value,
         );
         if (!existingCreatedCourse) {
+          // Adds new created course object with given data.
           this.createdCourses.push({
             subject: subject.id,
             groups: JSON.parse(header.value),
@@ -271,20 +168,22 @@ export default {
             ...newValue,
           });
         } else {
-          Object.assign(existingCreatedCourse, newValue);
+          // Sets given data in existing created course object.
+          for (const key in newValue) {
+            this.$set(existingCreatedCourse, key, newValue[key]);
+          }
         }
       } else {
-        if (
-          !course.lrTimeboundCourseConfigs?.filter(
-            (c) => c.validityRange.id === this.internalValidityRange?.id,
-          ).length
-        ) {
+        // Course already exists.
+        if (!course.lrTimeboundCourseConfigs?.length) {
+          // No TCCs exist for given course.
           let existingCreatedCourseConfig = this.createdCourseConfigs.find(
             (c) =>
               c.course === course.id &&
               c.validityRange === this.internalValidityRange?.id,
           );
           if (!existingCreatedCourseConfig) {
+            // Adds new TCC object with given data.
             this.createdCourseConfigs.push({
               course: course.id,
               validityRange: this.internalValidityRange?.id,
@@ -293,136 +192,162 @@ export default {
               ...newValue,
             });
           } else {
-            Object.assign(existingCreatedCourseConfig, newValue);
+            // Sets given data in existing TCC object.
+            for (const key in newValue) {
+              this.$set(existingCreatedCourseConfig, key, newValue[key]);
+            }
           }
         } else {
+          // TCC already exists
           let courseConfigID = course.lrTimeboundCourseConfigs[0].id;
           let existingEditedCourseConfig = this.editedCourseConfigs.find(
             (c) => c.id === courseConfigID,
           );
           if (!existingEditedCourseConfig) {
+            // Adds new object representing edits made to existing TCC.
             this.editedCourseConfigs.push({ id: courseConfigID, ...newValue });
           } else {
-            Object.assign(existingEditedCourseConfig, newValue);
+            // Sets given data in existing TCC edit object.
+            for (const key in newValue) {
+              this.$set(existingEditedCourseConfig, key, newValue[key]);
+            }
           }
         }
       }
     },
-    save() {
-      this.loading = true;
-
-      for (let mutationCombination of [
-        {
-          data: this.editedCourseConfigs,
-          mutation: updateTimeboundCourseConfigs,
-        },
-        {
-          data: this.createdCourseConfigs,
-          mutation: createTimeboundCourseConfigs,
-        },
-        {
-          data: this.createdCourses,
-          mutation: createCoursesForSchoolTerm,
-        },
-      ]) {
-        if (mutationCombination.data.length) {
-          this.$apollo
-            .mutate({
-              mutation: mutationCombination.mutation,
-              variables: {
-                input: mutationCombination.data,
-              },
-            })
-            .catch(() => {}); // FIXME Error Handling
+    updateCourseConfigs(cachedSubjects, incomingCourseConfigs) {
+      // Handles incoming TCCs on partial update after mutation.
+      // Find related existing course by subject and course ID.
+      incomingCourseConfigs.forEach((newCourseConfig) => {
+        const subject = cachedSubjects.find(
+          (s) => s.id === newCourseConfig.course.subject.id,
+        );
+        if (!subject) {
+          return;
         }
-      }
 
-      this.editedCourseConfigs = [];
-      this.createdCourseConfigs = [];
-      this.createdCourses = [];
-      this.$apollo.queries.subjects.refetch();
-      this.loading = false;
+        const course = subject.courses.find(
+          (c) => c.id === newCourseConfig.course.id,
+        );
+
+        course.lrTimeboundCourseConfigs = [newCourseConfig];
+      });
+
+      return cachedSubjects;
     },
-    getTeacherList(subjectTeachers) {
-      return [
-        {
-          header: this.$t(
-            "lesrooster.timebound_course_config.subject_teachers",
-          ),
-        },
-        ...this.persons.filter((person) =>
-          subjectTeachers.find((teacher) => teacher.id === person.id),
-        ),
-        { divider: true },
-        { header: this.$t("lesrooster.timebound_course_config.all_teachers") },
-        ...this.persons.filter(
-          (person) =>
-            !subjectTeachers.find((teacher) => teacher.id === person.id),
-        ),
-      ];
+    updateCreatedCourses(cachedSubjects, incomingCourses) {
+      // Handles incoming courses on partial update after mutation.
+      // Insert course into existing data by subject ID.
+      incomingCourses.forEach((newCourse) => {
+        const subject = cachedSubjects.find(
+          (s) => s.id === newCourse.subject.id,
+        );
+        if (!subject) {
+          return;
+        }
+
+        subject.courses.push(newCourse);
+      });
+
+      return cachedSubjects;
     },
     addCourse(subject, groups) {
-      let courseSubjectGroup = this.newCourses.find(
-        (courseSubject) => courseSubject.subject === subject,
+      // Handles clicks on "+" button.
+      // Adds course for given subject/groups combination and marks it as newly created.
+      this.$set(
+        this.items.find((i) => i.subject.id === subject),
+        groups,
+        [{ teachers: [], newCourse: true }],
       );
-      if (courseSubjectGroup) {
-        if (courseSubjectGroup.groupCombinations) {
-          this.$set(courseSubjectGroup.groupCombinations, groups, [
-            { teachers: [], newCourse: true },
-          ]);
-        } else {
-          courseSubjectGroup.groupCombinations = {
-            [groups]: [{ teachers: [], newCourse: true }],
-          };
-        }
-      } else {
-        this.newCourses.push({
-          subject: subject,
-          groupCombinations: { [groups]: [{ teachers: [], newCourse: true }] },
+    },
+    generateTableItems(subjects) {
+      // Generates items for data table, sorted by subjects.
+      const subjectsWithSortedCourses = subjects.map((subject) => {
+        let { courses, ...reducedSubject } = subject;
+        let groupCombinations = {};
+
+        courses.forEach((course) => {
+          // Aggregates all relevant group IDs.
+          const ownGroupIDs = course.groups.map((group) => group.id);
+          let groupIDs;
+
+          // If child groups should be included, add them.
+          if (
+            this.includeChildGroups &&
+            ownGroupIDs.some((groupID) => !this.groupIDSet.has(groupID))
+          ) {
+            groupIDs = JSON.stringify(
+              course.groups
+                .flatMap((group) =>
+                  group.parentGroups.map((parentGroup) => parentGroup.id),
+                )
+                .sort(),
+            );
+          } else {
+            groupIDs = JSON.stringify(ownGroupIDs.sort());
+          }
+
+          // Based on stringified aggregated groups, add group combination entry for subject.
+          if (!groupCombinations[groupIDs]) {
+            groupCombinations[groupIDs] = [];
+            if (course.groups.length > 1) {
+              this.groupCombinationsSet.add(groupIDs);
+            }
+            groupCombinations[groupIDs].push({
+              ...course,
+            });
+          } else if (
+            !groupCombinations[groupIDs].some((c) => c.id === course.id)
+          ) {
+            groupCombinations[groupIDs].push({
+              ...course,
+            });
+          }
         });
-      }
+
+        return {
+          subject: reducedSubject,
+          ...Object.fromEntries(
+            this.groupHeaders.map((header) => [header.value, []]),
+          ),
+          ...groupCombinations,
+        };
+      });
+      return subjectsWithSortedCourses;
     },
   },
   computed: {
-    groupIDList() {
-      return this.selectedGroups.map((group) => group.id);
+    groupIDSet() {
+      // Group ID set without duplicates.
+      return new Set(this.selectedGroups.map((group) => group.id));
     },
-    subjectGroupCombinations() {
-      return [].concat.apply(
-        [],
-        this.items.map((subject) => Object.keys(subject.groupCombinations)),
-      );
+    groupCombinationHeaders() {
+      // Generates additional table headers based on unique group combinations found from existing courses.
+      return this.subjectGroupCombinations.map((combination) => {
+        let parsedCombination = JSON.parse(combination);
+        return {
+          text: parsedCombination
+            .map(
+              (groupID) =>
+                this.selectedGroups.find((group) => group.id === groupID)
+                  ?.shortName ||
+                this.selectedGroups.find((group) => group.id === groupID)
+                  ?.shortName,
+            )
+            .join(", "),
+          value: combination,
+        };
+      });
     },
     groupHeaders() {
-      return this.selectedGroups
-        .map((group) => ({
-          text: group.shortName,
-          value: JSON.stringify([group.id]),
-        }))
-        .concat(
-          this.subjectGroupCombinations.map((combination) => {
-            let parsedCombination = JSON.parse(combination);
-            return {
-              text: parsedCombination
-                .map(
-                  (groupID) =>
-                    this.groups.find((group) => group.id === groupID).shortName,
-                )
-                .join(", "),
-              value: combination,
-            };
-          }),
-        )
-        .filter(
-          (obj, index, self) =>
-            index === self.findIndex((o) => o.value === obj.value),
-        );
+      return [...this.selectedGroupHeaders, ...this.groupCombinationHeaders];
     },
     headers() {
       let groupHeadersWithWidth = this.groupHeaders.map((header) => ({
         ...header,
-        width: `${Math.max(95 / this.groupHeaders.length, 15)}vw`,
+        width: "20vw",
       }));
+      // Adds column for subjects.
       return [
         {
           text: this.$t("lesrooster.timebound_course_config.subject"),
@@ -431,52 +356,112 @@ export default {
         },
       ].concat(groupHeadersWithWidth);
     },
-    items() {
-      return this.subjects.map((subject) => {
-        let groupCombinations = {};
-
-        subject.courses.forEach((course) => {
-          let groupIds = JSON.stringify(
-            course.groups.map((group) => group.id).sort(),
+    subjectGroupCombinations() {
+      return Array.from(this.groupCombinationsSet);
+    },
+    createdCoursesReady() {
+      // Indicates whether local created course data is ready to be used in mutation.
+      return (
+        !!this.createdCourses.length &&
+        this.createdCourses.every((c) => {
+          return (
+            c?.groups?.length && c.name && c.subject && c?.teachers?.length
+          );
+        })
+      );
+    },
+    createdCourseConfigsReady() {
+      // Indicates whether local created TCCs data is ready to be used in mutation.
+      return (
+        !!this.createdCourseConfigs.length &&
+        this.createdCourseConfigs.every((c) => {
+          return c.course && c.validityRange && c?.teachers?.length;
+        })
+      );
+    },
+    editedCourseConfigsReady() {
+      // Indicates whether local edited TCCs data is ready to be used in mutation.
+      return (
+        !!this.editedCourseConfigs.length &&
+        this.editedCourseConfigs.every((c) => {
+          return (
+            c.id && (Object.hasOwn(c, "lessonQuota") || c?.teachers?.length)
+          );
+        })
+      );
+    },
+    expandedQuery() {
+      return {
+        ...this.$apollo.queries.subjects.options,
+        variables: JSON.parse(
+          this.$apollo.queries.subjects.previousVariablesJson,
+        ),
+      };
+    },
+    tableLoading() {
+      return (
+        this.loading ||
+        this.$apollo.queries.subjects.loading ||
+        this.$apollo.queries.groupsForPlanning.loading ||
+        this.$apollo.queries.currentValidityRange.loading
+      );
+    },
+  },
+  watch: {
+    selectedGroups(newValue) {
+      this.selectedGroupHeaders = newValue.map((group) => ({
+        text: group.shortName,
+        value: JSON.stringify([group.id]),
+      }));
+    },
+    editedCourseConfigs: {
+      deep: true,
+      handler(newValue) {
+        if (this.editedCourseConfigsReady) {
+          this.mutate(
+            updateTimeboundCourseConfigs,
+            {
+              input: newValue,
+            },
+            this.updateCourseConfigs,
           );
 
-          if (!groupCombinations[groupIds]) {
-            groupCombinations[groupIds] = [];
-          }
-
-          if (!groupCombinations[groupIds].find((c) => c.id === course.id)) {
-            groupCombinations[groupIds].push({
-              ...course,
-            });
-          }
-        });
-
-        subject = {
-          ...subject,
-          groupCombinations: { ...groupCombinations },
-          newCourses: {
-            ...this.newCourses.find(
-              (courseSubject) => courseSubject.subject === subject.id,
-            )?.groupCombinations,
-          },
-        };
+          this.editedCourseConfigs = [];
+        }
+      },
+    },
+    createdCourseConfigs: {
+      deep: true,
+      handler(newValue) {
+        if (this.createdCourseConfigsReady) {
+          this.mutate(
+            createTimeboundCourseConfigs,
+            {
+              input: newValue,
+            },
+            this.updateCourseConfigs,
+          );
 
-        return subject;
-      });
+          this.createdCourseConfigs = [];
+        }
+      },
     },
-    tableItems() {
-      return this.items.map((subject) => {
-        // eslint-disable-next-line no-unused-vars
-        let { courses, groupCombinations, ...reducedSubject } = subject;
-        return {
-          subject: reducedSubject,
-          ...Object.fromEntries(
-            this.groupHeaders.map((header) => [header.value, []]),
-          ),
-          ...subject.groupCombinations,
-          ...subject.newCourses,
-        };
-      });
+    createdCourses: {
+      deep: true,
+      handler(newValue) {
+        if (this.createdCoursesReady) {
+          this.mutate(
+            createCoursesForValidityRange,
+            {
+              input: newValue,
+              validityRange: this.internalValidityRange.id,
+            },
+            this.updateCreatedCourses,
+          );
+
+          this.createdCourses = [];
+        }
+      },
     },
   },
   apollo: {
@@ -487,30 +472,29 @@ export default {
         this.internalValidityRange = data.currentValidityRange;
       },
     },
-    groups: {
-      query: gqlGroups,
-    },
     groupsForPlanning: {
       query: gqlGroupsForPlanning,
       result({ data }) {
         if (!data) return;
-        console.log(data.groups);
         this.selectedGroups = data.groupsForPlanning;
       },
     },
     subjects: {
       query: subjects,
       skip() {
-        return !this.groupIDList.length;
+        return !this.groupIDSet.size || !this.internalValidityRange;
       },
       variables() {
         return {
-          groups: this.groupIDList,
+          groups: Array.from(this.groupIDSet),
+          includeChildGroups: this.includeChildGroups,
         };
       },
-    },
-    persons: {
-      query: gqlTeachers,
+      update: (data) => data.items,
+      result({ data }) {
+        if (!data) return;
+        this.items = this.generateTableItems(data.items);
+      },
     },
   },
 };
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue
new file mode 100644
index 0000000000000000000000000000000000000000..75b7dc153530d62e61879c61995a7bd52da52a47
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue
@@ -0,0 +1,121 @@
+<script setup>
+import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
+import TeacherField from "aleksis.apps.cursus/components/TeacherField.vue";
+</script>
+
+<template>
+  <v-lazy
+    v-model="active"
+    :options="{
+      threshold: 0.5,
+    }"
+    transition="fade-transition"
+  >
+    <div v-if="value.length">
+      <v-row
+        v-for="(course, index) in value"
+        :key="index"
+        no-gutters
+        class="mt-2"
+      >
+        <v-col cols="6">
+          <positive-small-integer-field
+            dense
+            filled
+            class="mx-1"
+            :disabled="loading"
+            :value="
+              getCurrentCourseConfig(course)
+                ? getCurrentCourseConfig(course).lessonQuota
+                : course.lessonQuota
+            "
+            :label="$t('lesrooster.timebound_course_config.lesson_quota')"
+            @change="
+              (event) =>
+                $emit('setCourseConfigData', course, subject, header, {
+                  lessonQuota: event,
+                })
+            "
+          />
+        </v-col>
+        <v-col cols="6">
+          <teacher-field
+            dense
+            filled
+            class="mx-1"
+            :disabled="loading"
+            :label="$t('lesrooster.timebound_course_config.teachers')"
+            :value="
+              getCurrentCourseConfig(course)
+                ? getCurrentCourseConfig(course).teachers
+                : course.teachers
+            "
+            :show-subjects="true"
+            :priority-subject="subject"
+            :rules="$rules().isNonEmpty.build()"
+            @input="
+              (event) =>
+                $emit('setCourseConfigData', course, subject, header, {
+                  teachers: event,
+                })
+            "
+          />
+        </v-col>
+      </v-row>
+    </div>
+    <div v-else>
+      <v-btn
+        block
+        icon
+        tile
+        outlined
+        @click="$emit('addCourse', subject.id, header.value)"
+      >
+        <v-icon>mdi-plus</v-icon>
+      </v-btn>
+    </div>
+  </v-lazy>
+</template>
+
+<script>
+import formRulesMixin from "aleksis.core/mixins/formRulesMixin";
+
+export default {
+  name: "TimeboundCourseConfigRasterCell",
+  mixins: [formRulesMixin],
+  emits: ["addCourse", "setCourseConfigData"],
+  props: {
+    value: {
+      type: Array,
+      required: true,
+    },
+    subject: {
+      type: Object,
+      required: true,
+    },
+    header: {
+      type: Object,
+      required: true,
+    },
+    loading: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      active: false,
+    };
+  },
+  methods: {
+    getCurrentCourseConfig(course) {
+      if (course.lrTimeboundCourseConfigs?.length) {
+        return course.lrTimeboundCourseConfigs[0];
+      } else {
+        return null;
+      }
+    },
+  },
+};
+</script>
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
index c70792897146cc84e6afb74ac070d92ab9d0dfd7..15d3954d60538d2fe39acdd2453b2fd9c58bc845 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
@@ -23,6 +23,11 @@ fragment courseFields on LesroosterExtendedCourseType {
     id
     name
     shortName
+    parentGroups {
+      id
+      name
+      shortName
+    }
   }
   lessonQuota
 }
@@ -93,11 +98,17 @@ mutation updateTimeboundCourseConfigs(
   }
 }
 
-query subjects($orderBy: [String], $filters: JSONString, $groups: [ID]) {
-  subjects: lesroosterExtendedSubjects(
+query subjects(
+  $orderBy: [String]
+  $filters: JSONString
+  $groups: [ID]
+  $includeChildGroups: Boolean!
+) {
+  items: lesroosterExtendedSubjects(
     orderBy: $orderBy
     filters: $filters
     groups: $groups
+    includeChildGroups: $includeChildGroups
   ) {
     ...subjectFields
     courses {
@@ -109,24 +120,19 @@ query subjects($orderBy: [String], $filters: JSONString, $groups: [ID]) {
   }
 }
 
-mutation createCoursesForSchoolTerm($input: [CourseInputType]!) {
-  createCoursesForSchoolTerm(input: $input) {
+mutation createCoursesForValidityRange(
+  $input: [CourseInputType]!
+  $validityRange: ID!
+) {
+  createCoursesForValidityRange(input: $input, validityRange: $validityRange) {
     items: courses {
-      id
-      name
+      ...courseFields
       subject {
-        id
-        name
-      }
-      teachers {
-        id
-        fullName
+        ...subjectFields
       }
-      groups {
-        id
-        name
+      lrTimeboundCourseConfigs {
+        ...timeboundCourseConfigFields
       }
-      lessonQuota
       canEdit
       canDelete
     }
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue
index 5be88f33b042d90d7cb241819a7df2c69f0e8936..8ff63e95d635080c13e135a1e484366cb3037a3a 100644
--- a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue
@@ -9,6 +9,7 @@ import DateField from "aleksis.core/components/generic/forms/DateField.vue";
     v-on="$listeners"
     :fields="headers"
     create-item-i18n-key="lesrooster.validity_range.create_validity_range"
+    :label="$t('labels.select_validity_range')"
     :gql-query="gqlQuery"
     :gql-create-mutation="gqlCreateMutation"
     :gql-patch-mutation="{}"
diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json
index 9a73841fd9d09a333e582359d3f4f60dfb8b5b18..72325fa572621c2e33dc878d6b4d10cd7025a154 100644
--- a/aleksis/apps/lesrooster/frontend/messages/en.json
+++ b/aleksis/apps/lesrooster/frontend/messages/en.json
@@ -87,7 +87,10 @@
       "all_teachers": "All teachers",
       "no_course_selected": "No course selected",
       "create_timebound_course_config": "Create timebound course config",
-      "subject": "Subject"
+      "subject": "Subject",
+      "filters": {
+        "include_child_groups": "Include courses from child groups"
+      }
     },
     "lesson_raster": {
       "menu_title": "Lesson Raster"
diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 9525137be45549e57dabccfef74b6a9600cae5ce..012b9ec38afe13421a651c7ee1fe337fe0e27ec4 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -480,8 +480,10 @@ class LessonBundle(ExtensibleModel):
         )
 
         lesson_bundle.lessons.set(
-            [Lesson.create_from_course(course, validity_range)
-             for course in course_bundle.courses.all()]
+            [
+                Lesson.create_from_course(course, validity_range)
+                for course in course_bundle.courses.all()
+            ]
         )
 
         return lesson_bundle
@@ -553,9 +555,7 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
     def create_from_course(cls, course: Course, validity_range: ValidityRange) -> "Lesson":
         """Create a lesson from a course backed by a validity range."""
         # Lookup the TCC for the course in the validity_range
-        tcc = TimeboundCourseConfig.objects.get(
-            course=course, validity_range=validity_range
-        )
+        tcc = TimeboundCourseConfig.objects.get(course=course, validity_range=validity_range)
         lesson = cls.objects.create(
             course=course,
             subject=course.subject,
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index 4445ee51d9dcc347d21801b059d621da29432768..f67ad826acaa4fab36c41b7b1be0cb8de700f4c1 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -58,7 +58,7 @@ from .time_grid import (
 )
 from .timebound_course_bundle import TimeboundCourseBundleType
 from .timebound_course_config import (
-    CourseBatchCreateForSchoolTermMutation,
+    CourseBatchCreateForValidityRangeMutation,
     LesroosterExtendedSubjectType,
     TimeboundCourseConfigBatchCreateMutation,
     TimeboundCourseConfigBatchDeleteMutation,
@@ -126,7 +126,9 @@ class Query(graphene.ObjectType):
     current_validity_range = graphene.Field(ValidityRangeType)
 
     lesrooster_extended_subjects = FilterOrderList(
-        LesroosterExtendedSubjectType, groups=graphene.List(graphene.ID)
+        LesroosterExtendedSubjectType,
+        groups=graphene.List(graphene.ID),
+        include_child_groups=graphene.Boolean(required=True),
     )
 
     groups_by_time_grid = graphene.List(GroupType, time_grid=graphene.ID(required=True))
@@ -203,15 +205,39 @@ class Query(graphene.ObjectType):
         return graphene_django_optimizer.query(qs, info)
 
     @staticmethod
-    def resolve_lesrooster_extended_subjects(root, info, groups):
+    def resolve_lesrooster_extended_subjects(root, info, groups, include_child_groups):
+        if include_child_groups:
+            courses = Course.objects.filter(
+                Q(groups__in=groups) | Q(groups__parent_groups__in=groups)
+            )
+        else:
+            courses = Course.objects.filter(groups__in=groups)
+
+        course_configs = filter_active_school_term(
+            info.context,
+            TimeboundCourseConfig.objects.all(),
+            "validity_range__school_term",
+        )
+        if not info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"):
+            course_configs = get_objects_for_user(
+                info.context.user,
+                "lesrooster.view_timeboundcourseconfig",
+                course_configs,
+            )
+
         subjects = Subject.objects.all().prefetch_related(
             Prefetch(
                 "courses",
                 queryset=get_objects_for_user(
-                    info.context.user, "cursus.view_course", Course.objects.all()
-                ).filter(groups__in=groups),
+                    info.context.user,
+                    "cursus.view_course",
+                    courses.prefetch_related(
+                        Prefetch("lr_timebound_course_configs", queryset=course_configs)
+                    ),
+                ),
             )
         )
+
         if not info.context.user.has_perm("lesrooster.view_subject_rule"):
             return graphene_django_optimizer.query(
                 get_objects_for_user(info.context.user, "cursus.view_subject", subjects), info
@@ -249,8 +275,9 @@ class Query(graphene.ObjectType):
             return []
 
         return graphene_django_optimizer.query(
-            CourseBundle.objects
-            .filter(Q(courses__groups__id=group) | Q(courses__groups__parent_groups__id=group))
+            CourseBundle.objects.filter(
+                Q(courses__groups__id=group) | Q(courses__groups__parent_groups__id=group)
+            )
             .distinct()
             .annotate(validity_range_id=Value(validity_range)),
             info,
@@ -427,4 +454,4 @@ class Mutation(graphene.ObjectType):
     delete_supervisions = SupervisionBatchDeleteMutation.Field()
     update_supervisions = SupervisionBatchPatchMutation.Field()
 
-    create_courses_for_school_term = CourseBatchCreateForSchoolTermMutation.Field()
+    create_courses_for_validity_range = CourseBatchCreateForValidityRangeMutation.Field()
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_bundle.py b/aleksis/apps/lesrooster/schema/timebound_course_bundle.py
index 500be9e8d91124ecc6cca0581fa8d60feeb13191..918e44eb2b4ad0f926720ceb106ddbc7ed4eb077 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_bundle.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_bundle.py
@@ -15,22 +15,16 @@ class TimeboundCourseType(BaseCourseType):
 
     def resolve_lesson_quota(root, info):
         """Resolve lesson_quota from timebound_course_config"""
-        return (
-            TimeboundCourseConfig.objects
-            .get(
-                course=root,
-                validity_range__id=root.validity_range_id,
-            )
-            .lesson_quota
-        )
+        return TimeboundCourseConfig.objects.get(
+            course=root,
+            validity_range__id=root.validity_range_id,
+        ).lesson_quota
 
     def resolve_teachers(root, info):
         """Resolve teachers from timebound_course_config"""
-        return (
-            TimeboundCourseConfig.objects
-            .get(course=root, validity_range__id=root.validity_range_id)
-            .teachers.all()
-        )
+        return TimeboundCourseConfig.objects.get(
+            course=root, validity_range__id=root.validity_range_id
+        ).teachers.all()
 
 
 class TimeboundCourseBundleType(BaseCourseBundleType):
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
index ab647bca271aa3689c349dcc6dcfe60c431dd26f..b8a38461f433e8ab45baf332f7f4d679e1532b43 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_config.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -8,7 +8,6 @@ from graphene_django_cud.mutations import (
     DjangoBatchDeleteMutation,
     DjangoBatchPatchMutation,
 )
-from guardian.shortcuts import get_objects_for_user
 
 from aleksis.apps.cursus.models import Course, Subject
 from aleksis.apps.cursus.schema import CourseType, SubjectType
@@ -23,7 +22,7 @@ from aleksis.core.util.core_helpers import (
     get_site_preferences,
 )
 
-from ..models import TimeboundCourseConfig
+from ..models import TimeboundCourseConfig, ValidityRange
 
 timebound_course_config_filters = {"course": ["in"], "validity_range": ["in"], "teachers": [""]}
 
@@ -67,13 +66,9 @@ class LesroosterExtendedCourseType(CourseType):
 
     @staticmethod
     def resolve_lr_timebound_course_configs(root, info, **kwargs):
-        if not info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"):
-            return get_objects_for_user(
-                info.context.user,
-                "lesrooster.view_timeboundcourseconfig",
-                root.lr_timebound_course_configs.all(),
-            )
-        return root.lr_timebound_course_configs.all()
+        if info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"):
+            return root.lr_timebound_course_configs.all()
+        return []
 
 
 class LesroosterExtendedSubjectType(SubjectType):
@@ -113,26 +108,36 @@ class CourseInputType(graphene.InputObjectType):
     default_room = graphene.ID(required=False)
 
 
-class CourseBatchCreateForSchoolTermMutation(graphene.Mutation):
+class CourseBatchCreateForValidityRangeMutation(graphene.Mutation):
     class Arguments:
         input = graphene.List(CourseInputType)
+        validity_range = graphene.ID()
 
-    courses = graphene.List(CourseType)
+    courses = graphene.List(LesroosterExtendedCourseType)
 
     @classmethod
-    def create(cls, info, course_input):
+    def create(cls, info, course_input, validity_range):
         if info.context.user.has_perm("cursus.create_course_rule"):
             groups = Group.objects.filter(pk__in=course_input.groups)
             subject = Subject.objects.get(pk=course_input.subject)
             teachers = Person.objects.filter(pk__in=course_input.teachers)
+            validity_range = ValidityRange.objects.get(pk=validity_range)
 
             course = Course.objects.create(
-                name=course_input.name,
+                name=f"""{''.join(groups.values_list('short_name', flat=True)
+                .order_by('short_name'))}-{subject.name}""",
                 subject=subject,
                 lesson_quota=course_input.lesson_quota or None,
             )
             course.teachers.set(teachers)
 
+            tcc = TimeboundCourseConfig.objects.create(
+                course=course,
+                validity_range=validity_range,
+                lesson_quota=course.lesson_quota,
+            )
+            tcc.teachers.set(course.teachers.all())
+
             if get_site_preferences()["lesrooster__create_course_group"]:
                 school_term = get_active_school_term(info.context)
 
@@ -163,6 +168,6 @@ class CourseBatchCreateForSchoolTermMutation(graphene.Mutation):
             return course
 
     @classmethod
-    def mutate(cls, root, info, input):  # noqa
-        objs = [cls.create(info, course_input) for course_input in input]
-        return CourseBatchCreateForSchoolTermMutation(courses=objs)
+    def mutate(cls, root, info, input, validity_range):  # noqa
+        objs = [cls.create(info, course_input, validity_range) for course_input in input]
+        return CourseBatchCreateForValidityRangeMutation(courses=objs)